React Native Integration
React Native Integration
This guide covers how to integrate Skycloak authentication into React Native applications using react-native-app-auth and other modern authentication patterns.
Prerequisites
- React Native 0.70+
- Node.js 16+
- Skycloak cluster with configured realm and mobile client
- iOS 12.0+ / Android 5.0+ (API level 21+)
- Basic understanding of React Native development
Quick Start
1. Install Dependencies
npm install react-native-app-auth react-native-keychain react-native-inappbrowser-reborn
npm install jwt-decode @react-native-async-storage/async-storage
npm install react-native-vector-icons @react-navigation/native @react-navigation/stack
# For iOS
cd ios && pod install2. Platform Configuration
iOS Configuration
Update ios/YourApp/Info.plist:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>com.yourcompany.app</string>
</array>
</dict>
</array>Update ios/YourApp/AppDelegate.mm:
#import <React/RCTLinkingManager.h>
// Add this method
- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url
options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options
{
return [RCTLinkingManager application:app openURL:url options:options];
}Android Configuration
Update android/app/build.gradle:
android {
defaultConfig {
manifestPlaceholders = [
appAuthRedirectScheme: 'com.yourcompany.app'
]
}
}Update android/app/src/main/AndroidManifest.xml:
<!-- Add inside <application> tag -->
<activity
android:name="net.openid.appauth.RedirectUriReceiverActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.yourcompany.app" />
</intent-filter>
</activity>3. Create Auth Configuration
// src/config/auth.config.ts
export const AuthConfig = {
issuer: 'https://your-cluster-id.app.skycloak.io/realms/your-realm',
clientId: 'your-react-native-app',
redirectUrl: 'com.yourcompany.app://oauth/callback',
postLogoutRedirectUrl: 'com.yourcompany.app://oauth/logout',
scopes: ['openid', 'profile', 'email', 'offline_access'],
additionalParameters: {},
customHeaders: {},
// Optional: For Android 11+ to handle Chrome Custom Tabs
warmAndPrefetchChrome: true,
// Optional: Use ephemeral session (doesn't save cookies)
useNonce: true,
usePKCE: true,
};4. Create Auth Service
// src/services/AuthService.ts
import { authorize, refresh, revoke, AuthConfiguration } from 'react-native-app-auth';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Keychain from 'react-native-keychain';
import { decode as jwtDecode } from 'jwt-decode';
import { AuthConfig } from '../config/auth.config';
interface AuthState {
hasLoggedIn: boolean;
accessToken: string | null;
refreshToken: string | null;
idToken: string | null;
accessTokenExpirationDate: string | null;
userInfo: UserInfo | null;
}
interface UserInfo {
id: string;
username: string;
email: string;
name: string;
roles: string[];
groups: string[];
}
interface JWTToken {
sub: string;
preferred_username: string;
email: string;
name: string;
realm_access?: {
roles: string[];
};
resource_access?: {
[key: string]: {
roles: string[];
};
};
groups?: string[];
exp: number;
}
class AuthService {
private static instance: AuthService;
private authState: AuthState = {
hasLoggedIn: false,
accessToken: null,
refreshToken: null,
idToken: null,
accessTokenExpirationDate: null,
userInfo: null,
};
private constructor() {
this.loadAuthState();
}
static getInstance(): AuthService {
if (!AuthService.instance) {
AuthService.instance = new AuthService();
}
return AuthService.instance;
}
// Initialize auth state from storage
private async loadAuthState(): Promise<void> {
try {
const credentials = await Keychain.getInternetCredentials('skycloak_auth');
if (credentials) {
const tokens = JSON.parse(credentials.password);
this.authState = {
hasLoggedIn: true,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
idToken: tokens.idToken,
accessTokenExpirationDate: tokens.accessTokenExpirationDate,
userInfo: this.extractUserInfo(tokens.idToken),
};
}
} catch (error) {
console.error('Failed to load auth state:', error);
}
}
// Save auth state to secure storage
private async saveAuthState(): Promise<void> {
try {
if (this.authState.hasLoggedIn && this.authState.accessToken) {
await Keychain.setInternetCredentials(
'skycloak_auth',
'tokens',
JSON.stringify({
accessToken: this.authState.accessToken,
refreshToken: this.authState.refreshToken,
idToken: this.authState.idToken,
accessTokenExpirationDate: this.authState.accessTokenExpirationDate,
})
);
} else {
await Keychain.resetInternetCredentials('skycloak_auth');
}
} catch (error) {
console.error('Failed to save auth state:', error);
}
}
// Extract user info from ID token
private extractUserInfo(idToken: string | null): UserInfo | null {
if (!idToken) return null;
try {
const decoded = jwtDecode<JWTToken>(idToken);
// Extract roles
const realmRoles = decoded.realm_access?.roles || [];
const clientRoles = decoded.resource_access?.[AuthConfig.clientId]?.roles || [];
const allRoles = [...realmRoles, ...clientRoles];
return {
id: decoded.sub,
username: decoded.preferred_username,
email: decoded.email,
name: decoded.name,
roles: allRoles,
groups: decoded.groups || [],
};
} catch (error) {
console.error('Failed to decode ID token:', error);
return null;
}
}
// Login
async login(): Promise<void> {
try {
const result = await authorize(AuthConfig);
this.authState = {
hasLoggedIn: true,
accessToken: result.accessToken,
refreshToken: result.refreshToken,
idToken: result.idToken,
accessTokenExpirationDate: result.accessTokenExpirationDate,
userInfo: this.extractUserInfo(result.idToken),
};
await this.saveAuthState();
} catch (error) {
console.error('Authentication failed:', error);
throw error;
}
}
// Refresh token
async refreshToken(): Promise<void> {
if (!this.authState.refreshToken) {
throw new Error('No refresh token available');
}
try {
const result = await refresh(AuthConfig, {
refreshToken: this.authState.refreshToken,
});
this.authState = {
...this.authState,
accessToken: result.accessToken,
refreshToken: result.refreshToken || this.authState.refreshToken,
idToken: result.idToken,
accessTokenExpirationDate: result.accessTokenExpirationDate,
userInfo: this.extractUserInfo(result.idToken),
};
await this.saveAuthState();
} catch (error) {
console.error('Token refresh failed:', error);
// If refresh fails, clear auth state
await this.logout();
throw error;
}
}
// Logout
async logout(): Promise<void> {
try {
if (this.authState.idToken) {
await revoke(AuthConfig, {
tokenToRevoke: this.authState.idToken,
includeBasicAuth: false,
sendClientId: true,
});
}
} catch (error) {
console.error('Revoke token failed:', error);
}
// Clear auth state
this.authState = {
hasLoggedIn: false,
accessToken: null,
refreshToken: null,
idToken: null,
accessTokenExpirationDate: null,
userInfo: null,
};
await Keychain.resetInternetCredentials('skycloak_auth');
}
// Get access token (with auto-refresh)
async getAccessToken(): Promise<string | null> {
if (!this.authState.accessToken) {
return null;
}
// Check if token is expired
if (this.isTokenExpired()) {
try {
await this.refreshToken();
} catch (error) {
return null;
}
}
return this.authState.accessToken;
}
// Check if token is expired
private isTokenExpired(): boolean {
if (!this.authState.accessTokenExpirationDate) {
return true;
}
const expirationDate = new Date(this.authState.accessTokenExpirationDate);
const now = new Date();
// Refresh if less than 5 minutes remaining
const bufferTime = 5 * 60 * 1000;
return expirationDate.getTime() - now.getTime() < bufferTime;
}
// Get current auth state
getAuthState(): AuthState {
return { ...this.authState };
}
// Check if user is authenticated
isAuthenticated(): boolean {
return this.authState.hasLoggedIn && !!this.authState.accessToken;
}
// Get user info
getUserInfo(): UserInfo | null {
return this.authState.userInfo;
}
// Check if user has a specific role
hasRole(role: string): boolean {
return this.authState.userInfo?.roles.includes(role) || false;
}
// Check if user has any of the specified roles
hasAnyRole(...roles: string[]): boolean {
const userRoles = this.authState.userInfo?.roles || [];
return roles.some(role => userRoles.includes(role));
}
// Check if user has all specified roles
hasAllRoles(...roles: string[]): boolean {
const userRoles = this.authState.userInfo?.roles || [];
return roles.every(role => userRoles.includes(role));
}
// Check if user is in a specific group
inGroup(group: string): boolean {
return this.authState.userInfo?.groups.includes(group) || false;
}
}
export default AuthService.getInstance();5. Create Auth Context
// src/contexts/AuthContext.tsx
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import AuthService from '../services/AuthService';
interface AuthContextType {
isAuthenticated: boolean;
isLoading: boolean;
userInfo: UserInfo | null;
login: () => Promise<void>;
logout: () => Promise<void>;
refreshToken: () => Promise<void>;
hasRole: (role: string) => boolean;
hasAnyRole: (...roles: string[]) => boolean;
hasAllRoles: (...roles: string[]) => boolean;
inGroup: (group: string) => boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
interface AuthProviderProps {
children: ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
useEffect(() => {
checkAuthState();
}, []);
const checkAuthState = async () => {
try {
const authState = AuthService.getAuthState();
setIsAuthenticated(authState.hasLoggedIn);
setUserInfo(authState.userInfo);
// Try to refresh token if authenticated
if (authState.hasLoggedIn) {
try {
await AuthService.refreshToken();
const newState = AuthService.getAuthState();
setUserInfo(newState.userInfo);
} catch (error) {
// Refresh failed, user needs to login again
setIsAuthenticated(false);
setUserInfo(null);
}
}
} catch (error) {
console.error('Failed to check auth state:', error);
} finally {
setIsLoading(false);
}
};
const login = async () => {
setIsLoading(true);
try {
await AuthService.login();
const authState = AuthService.getAuthState();
setIsAuthenticated(true);
setUserInfo(authState.userInfo);
} finally {
setIsLoading(false);
}
};
const logout = async () => {
setIsLoading(true);
try {
await AuthService.logout();
setIsAuthenticated(false);
setUserInfo(null);
} finally {
setIsLoading(false);
}
};
const refreshToken = async () => {
await AuthService.refreshToken();
const authState = AuthService.getAuthState();
setUserInfo(authState.userInfo);
};
const value: AuthContextType = {
isAuthenticated,
isLoading,
userInfo,
login,
logout,
refreshToken,
hasRole: (role) => AuthService.hasRole(role),
hasAnyRole: (...roles) => AuthService.hasAnyRole(...roles),
hasAllRoles: (...roles) => AuthService.hasAllRoles(...roles),
inGroup: (group) => AuthService.inGroup(group),
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};UI Implementation
Login Screen
// src/screens/LoginScreen.tsx
import React from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
Image,
SafeAreaView,
} from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import Icon from 'react-native-vector-icons/MaterialIcons';
import { useAuth } from '../contexts/AuthContext';
const LoginScreen: React.FC = () => {
const { login, isLoading } = useAuth();
const [error, setError] = React.useState<string | null>(null);
const handleLogin = async () => {
try {
setError(null);
await login();
} catch (err) {
setError('Authentication failed. Please try again.');
console.error('Login error:', err);
}
};
return (
<LinearGradient
colors={['#4c669f', '#3b5998', '#192f6a']}
style={styles.container}
>
<SafeAreaView style={styles.safeArea}>
<View style={styles.content}>
<View style={styles.logoContainer}>
<Icon name="lock" size={80} color="#fff" />
<Text style={styles.title}>MyApp</Text>
<Text style={styles.subtitle}>Secure Authentication</Text>
</View>
{error && (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
<TouchableOpacity
style={[styles.loginButton, isLoading && styles.disabledButton]}
onPress={handleLogin}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color="#fff" />
) : (
<>
<Icon name="login" size={24} color="#fff" style={styles.buttonIcon} />
<Text style={styles.loginButtonText}>Sign In with Skycloak</Text>
</>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.registerButton}
onPress={handleLogin}
disabled={isLoading}
>
<Text style={styles.registerButtonText}>Create Account</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
</LinearGradient>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
safeArea: {
flex: 1,
},
content: {
flex: 1,
justifyContent: 'center',
paddingHorizontal: 40,
},
logoContainer: {
alignItems: 'center',
marginBottom: 60,
},
title: {
fontSize: 36,
fontWeight: 'bold',
color: '#fff',
marginTop: 20,
},
subtitle: {
fontSize: 18,
color: '#fff',
opacity: 0.8,
marginTop: 10,
},
errorContainer: {
backgroundColor: 'rgba(255, 0, 0, 0.1)',
padding: 15,
borderRadius: 8,
marginBottom: 20,
borderWidth: 1,
borderColor: 'rgba(255, 0, 0, 0.3)',
},
errorText: {
color: '#fff',
textAlign: 'center',
},
loginButton: {
backgroundColor: 'rgba(255, 255, 255, 0.2)',
paddingVertical: 15,
paddingHorizontal: 30,
borderRadius: 25,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 15,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.3)',
},
disabledButton: {
opacity: 0.7,
},
buttonIcon: {
marginRight: 10,
},
loginButtonText: {
color: '#fff',
fontSize: 18,
fontWeight: '600',
},
registerButton: {
paddingVertical: 15,
},
registerButtonText: {
color: '#fff',
fontSize: 16,
textAlign: 'center',
textDecorationLine: 'underline',
},
});
export default LoginScreen;Main App Navigation
// src/navigation/AppNavigator.tsx
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import Icon from 'react-native-vector-icons/MaterialIcons';
import { useAuth } from '../contexts/AuthContext';
import LoginScreen from '../screens/LoginScreen';
import HomeScreen from '../screens/HomeScreen';
import ProfileScreen from '../screens/ProfileScreen';
import AdminScreen from '../screens/AdminScreen';
import LoadingScreen from '../screens/LoadingScreen';
const Stack = createStackNavigator();
const Tab = createBottomTabNavigator();
const MainTabs: React.FC = () => {
const { hasRole } = useAuth();
return (
<Tab.Navigator
screenOptions={({ route }) => ({
tabBarIcon: ({ focused, color, size }) => {
let iconName: string;
if (route.name === 'Home') {
iconName = 'home';
} else if (route.name === 'Profile') {
iconName = 'person';
} else if (route.name === 'Admin') {
iconName = 'settings';
} else {
iconName = 'circle';
}
return <Icon name={iconName} size={size} color={color} />;
},
tabBarActiveTintColor: '#3b5998',
tabBarInactiveTintColor: 'gray',
})}
>
<Tab.Screen name="Home" component={HomeScreen} />
<Tab.Screen name="Profile" component={ProfileScreen} />
{hasRole('admin') && (
<Tab.Screen name="Admin" component={AdminScreen} />
)}
</Tab.Navigator>
);
};
const AppNavigator: React.FC = () => {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return <LoadingScreen />;
}
return (
<NavigationContainer>
<Stack.Navigator screenOptions={{ headerShown: false }}>
{isAuthenticated ? (
<Stack.Screen name="Main" component={MainTabs} />
) : (
<Stack.Screen name="Login" component={LoginScreen} />
)}
</Stack.Navigator>
</NavigationContainer>
);
};
export default AppNavigator;Protected Components
// src/components/ProtectedView.tsx
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { useAuth } from '../contexts/AuthContext';
interface ProtectedViewProps {
roles?: string[];
requireAll?: boolean;
fallback?: React.ReactNode;
children: React.ReactNode;
}
const ProtectedView: React.FC<ProtectedViewProps> = ({
roles = [],
requireAll = false,
fallback,
children,
}) => {
const { hasAnyRole, hasAllRoles } = useAuth();
const hasAccess = roles.length === 0 ||
(requireAll ? hasAllRoles(...roles) : hasAnyRole(...roles));
if (!hasAccess) {
return fallback ? (
<>{fallback}</>
) : (
<View style={styles.accessDenied}>
<Text style={styles.accessDeniedText}>
You don't have permission to view this content
</Text>
</View>
);
}
return <>{children}</>;
};
const styles = StyleSheet.create({
accessDenied: {
padding: 20,
alignItems: 'center',
},
accessDeniedText: {
color: '#666',
fontSize: 16,
},
});
export default ProtectedView;Home Screen
// src/screens/HomeScreen.tsx
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
RefreshControl,
} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons';
import { useAuth } from '../contexts/AuthContext';
import ProtectedView from '../components/ProtectedView';
import ApiService from '../services/ApiService';
const HomeScreen: React.FC = () => {
const { userInfo, logout } = useAuth();
const [refreshing, setRefreshing] = useState(false);
const [dashboardData, setDashboardData] = useState<any>(null);
useEffect(() => {
loadDashboardData();
}, []);
const loadDashboardData = async () => {
try {
const data = await ApiService.getDashboardData();
setDashboardData(data);
} catch (error) {
console.error('Failed to load dashboard data:', error);
}
};
const onRefresh = async () => {
setRefreshing(true);
await loadDashboardData();
setRefreshing(false);
};
const handleLogout = async () => {
await logout();
};
return (
<ScrollView
style={styles.container}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
>
<View style={styles.header}>
<Text style={styles.welcomeText}>
Welcome, {userInfo?.name || 'User'}!
</Text>
<TouchableOpacity onPress={handleLogout}>
<Icon name="logout" size={24} color="#666" />
</TouchableOpacity>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}>Your Dashboard</Text>
<Text style={styles.cardDescription}>
View your personalized content and metrics
</Text>
</View>
<ProtectedView roles={['admin', 'manager']}>
<View style={styles.card}>
<Text style={styles.cardTitle}>Management Tools</Text>
<Text style={styles.cardDescription}>
Access administrative features
</Text>
</View>
</ProtectedView>
<ProtectedView roles={['editor', 'moderator']}>
<View style={styles.card}>
<Text style={styles.cardTitle}>Content Management</Text>
<Text style={styles.cardDescription}>
Create and manage content
</Text>
</View>
</ProtectedView>
</ScrollView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
welcomeText: {
fontSize: 20,
fontWeight: 'bold',
color: '#333',
},
card: {
backgroundColor: '#fff',
padding: 20,
marginHorizontal: 20,
marginTop: 20,
borderRadius: 10,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 3.84,
elevation: 5,
},
cardTitle: {
fontSize: 18,
fontWeight: 'bold',
color: '#333',
marginBottom: 8,
},
cardDescription: {
fontSize: 14,
color: '#666',
},
});
export default HomeScreen;API Integration
API Service
// src/services/ApiService.ts
import AuthService from './AuthService';
interface RequestOptions extends RequestInit {
authenticated?: boolean;
}
class ApiService {
private baseURL: string;
constructor() {
this.baseURL = 'https://api.example.com';
}
private async request<T>(
endpoint: string,
options: RequestOptions = {}
): Promise<T> {
const { authenticated = true, ...fetchOptions } = options;
const url = `${this.baseURL}${endpoint}`;
const headers: HeadersInit = {
'Content-Type': 'application/json',
...fetchOptions.headers,
};
if (authenticated) {
const token = await AuthService.getAccessToken();
if (token) {
headers.Authorization = `Bearer ${token}`;
} else {
throw new Error('No access token available');
}
}
try {
const response = await fetch(url, {
...fetchOptions,
headers,
});
if (!response.ok) {
if (response.status === 401) {
// Token might be expired, try refreshing
await AuthService.refreshToken();
// Retry the request with new token
const newToken = await AuthService.getAccessToken();
if (newToken) {
headers.Authorization = `Bearer ${newToken}`;
const retryResponse = await fetch(url, {
...fetchOptions,
headers,
});
if (!retryResponse.ok) {
throw new Error(`API Error: ${retryResponse.statusText}`);
}
return retryResponse.json();
}
}
throw new Error(`API Error: ${response.statusText}`);
}
return response.json();
} catch (error) {
console.error('API request failed:', error);
throw error;
}
}
// API Methods
async getDashboardData() {
return this.request<any>('/dashboard');
}
async getUserProfile() {
return this.request<any>('/user/profile');
}
async updateUserProfile(data: any) {
return this.request<any>('/user/profile', {
method: 'PUT',
body: JSON.stringify(data),
});
}
async getUsers(page: number = 1) {
return this.request<any>(`/admin/users?page=${page}`);
}
async deleteUser(userId: string) {
return this.request<any>(`/admin/users/${userId}`, {
method: 'DELETE',
});
}
}
export default new ApiService();Hooks for API Calls
// src/hooks/useApi.ts
import { useState, useEffect, useCallback } from 'react';
interface UseApiOptions {
immediate?: boolean;
}
interface UseApiResult<T> {
data: T | null;
loading: boolean;
error: Error | null;
execute: (...args: any[]) => Promise<void>;
reset: () => void;
}
export function useApi<T>(
apiFunction: (...args: any[]) => Promise<T>,
options: UseApiOptions = {}
): UseApiResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const execute = useCallback(async (...args: any[]) => {
setLoading(true);
setError(null);
try {
const result = await apiFunction(...args);
setData(result);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
}, [apiFunction]);
const reset = useCallback(() => {
setData(null);
setError(null);
setLoading(false);
}, []);
useEffect(() => {
if (options.immediate) {
execute();
}
}, []);
return { data, loading, error, execute, reset };
}
// Usage example
const ProfileScreen: React.FC = () => {
const { data: profile, loading, error, execute: loadProfile } = useApi(
ApiService.getUserProfile,
{ immediate: true }
);
// Component implementation
};Testing
Unit Tests
// __tests__/AuthService.test.ts
import AuthService from '../src/services/AuthService';
import { authorize, refresh, revoke } from 'react-native-app-auth';
import * as Keychain from 'react-native-keychain';
jest.mock('react-native-app-auth');
jest.mock('react-native-keychain');
describe('AuthService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('login should store tokens', async () => {
const mockAuthResult = {
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
idToken: 'mock-id-token',
accessTokenExpirationDate: '2024-12-31T23:59:59Z',
};
(authorize as jest.Mock).mockResolvedValue(mockAuthResult);
(Keychain.setInternetCredentials as jest.Mock).mockResolvedValue(true);
await AuthService.login();
expect(authorize).toHaveBeenCalled();
expect(Keychain.setInternetCredentials).toHaveBeenCalledWith(
'skycloak_auth',
'tokens',
expect.any(String)
);
});
test('logout should clear tokens', async () => {
(revoke as jest.Mock).mockResolvedValue(undefined);
(Keychain.resetInternetCredentials as jest.Mock).mockResolvedValue(true);
await AuthService.logout();
expect(Keychain.resetInternetCredentials).toHaveBeenCalledWith('skycloak_auth');
expect(AuthService.isAuthenticated()).toBe(false);
});
test('hasRole should check user roles', () => {
// Mock auth state with roles
const mockUserInfo = {
id: '123',
username: 'testuser',
email: '[email protected]',
name: 'Test User',
roles: ['user', 'admin'],
groups: [],
};
// Use reflection to set private property (for testing only)
(AuthService as any).authState.userInfo = mockUserInfo;
expect(AuthService.hasRole('admin')).toBe(true);
expect(AuthService.hasRole('superadmin')).toBe(false);
});
});Integration Tests
// __tests__/integration/AuthFlow.test.tsx
import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import { NavigationContainer } from '@react-navigation/native';
import AppNavigator from '../src/navigation/AppNavigator';
import { AuthProvider } from '../src/contexts/AuthContext';
import AuthService from '../src/services/AuthService';
jest.mock('../src/services/AuthService');
describe('Authentication Flow', () => {
test('shows login screen when not authenticated', () => {
(AuthService.isAuthenticated as jest.Mock).mockReturnValue(false);
const { getByText } = render(
<AuthProvider>
<NavigationContainer>
<AppNavigator />
</NavigationContainer>
</AuthProvider>
);
expect(getByText('Sign In with Skycloak')).toBeTruthy();
});
test('shows home screen when authenticated', async () => {
(AuthService.isAuthenticated as jest.Mock).mockReturnValue(true);
(AuthService.getAuthState as jest.Mock).mockReturnValue({
hasLoggedIn: true,
userInfo: {
name: 'Test User',
roles: ['user'],
},
});
const { getByText } = render(
<AuthProvider>
<NavigationContainer>
<AppNavigator />
</NavigationContainer>
</AuthProvider>
);
await waitFor(() => {
expect(getByText(/Welcome, Test User/)).toBeTruthy();
});
});
});Production Considerations
Security Configuration
// src/utils/security.ts
import JailMonkey from 'jail-monkey';
import { NativeModules, Platform } from 'react-native';
export class SecurityManager {
static isDeviceSecure(): boolean {
// Check for jailbreak/root
if (JailMonkey.isJailBroken()) {
console.warn('Device is jailbroken/rooted');
return false;
}
// Check for debugger
if (JailMonkey.isDebuggedMode()) {
console.warn('App is being debugged');
return false;
}
// Check for external storage (Android)
if (Platform.OS === 'android' && JailMonkey.isOnExternalStorage()) {
console.warn('App is installed on external storage');
return false;
}
return true;
}
static enableCertificatePinning() {
// Implement certificate pinning
// This typically requires native module implementation
if (NativeModules.CertificatePinner) {
NativeModules.CertificatePinner.pin([
{
hostname: 'your-cluster-id.app.skycloak.io',
pin: 'sha256/YOUR_CERTIFICATE_PIN',
},
{
hostname: 'api.example.com',
pin: 'sha256/YOUR_API_CERTIFICATE_PIN',
},
]);
}
}
static obfuscateString(str: string): string {
// Simple obfuscation for sensitive strings
return Buffer.from(str).toString('base64');
}
static deobfuscateString(str: string): string {
return Buffer.from(str, 'base64').toString('utf-8');
}
}Performance Optimization
// src/utils/performance.ts
import React, { memo, useCallback, useMemo } from 'react';
import { InteractionManager } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
// Memoized component wrapper
export function withMemo<P extends object>(
Component: React.ComponentType<P>,
propsAreEqual?: (prevProps: P, nextProps: P) => boolean
) {
return memo(Component, propsAreEqual);
}
// Debounce hook
export function useDebounce<T extends (...args: any[]) => any>(
callback: T,
delay: number
): T {
const timeoutRef = React.useRef<NodeJS.Timeout>();
const debouncedCallback = useCallback(
(...args: Parameters<T>) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callback(...args);
}, delay);
},
[callback, delay]
) as T;
React.useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return debouncedCallback;
}
// Cache manager
export class CacheManager {
private static cacheKeyPrefix = '@app_cache:';
static async get<T>(key: string): Promise<T | null> {
try {
const cachedData = await AsyncStorage.getItem(
`${this.cacheKeyPrefix}${key}`
);
if (cachedData) {
const { data, expiry } = JSON.parse(cachedData);
if (expiry && new Date().getTime() > expiry) {
await this.remove(key);
return null;
}
return data;
}
return null;
} catch (error) {
console.error('Cache get error:', error);
return null;
}
}
static async set<T>(
key: string,
data: T,
expiryMinutes?: number
): Promise<void> {
try {
const cacheData = {
data,
expiry: expiryMinutes
? new Date().getTime() + expiryMinutes * 60 * 1000
: null,
};
await AsyncStorage.setItem(
`${this.cacheKeyPrefix}${key}`,
JSON.stringify(cacheData)
);
} catch (error) {
console.error('Cache set error:', error);
}
}
static async remove(key: string): Promise<void> {
try {
await AsyncStorage.removeItem(`${this.cacheKeyPrefix}${key}`);
} catch (error) {
console.error('Cache remove error:', error);
}
}
static async clear(): Promise<void> {
try {
const keys = await AsyncStorage.getAllKeys();
const cacheKeys = keys.filter(key =>
key.startsWith(this.cacheKeyPrefix)
);
await AsyncStorage.multiRemove(cacheKeys);
} catch (error) {
console.error('Cache clear error:', error);
}
}
}
// Performance monitoring
export function measurePerformance(name: string) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
const start = Date.now();
try {
const result = await originalMethod.apply(this, args);
const duration = Date.now() - start;
console.log(`[Performance] ${name}: ${duration}ms`);
return result;
} catch (error) {
const duration = Date.now() - start;
console.error(`[Performance] ${name} failed after ${duration}ms`);
throw error;
}
};
return descriptor;
};
}Error Boundary
// src/components/ErrorBoundary.tsx
import React, { ErrorInfo, ReactNode } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
SafeAreaView,
} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
// Log to crash reporting service
// crashlytics().recordError(error);
}
handleReset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return <>{this.props.fallback}</>;
}
return (
<SafeAreaView style={styles.container}>
<View style={styles.content}>
<Icon name="error-outline" size={80} color="#e74c3c" />
<Text style={styles.title}>Oops! Something went wrong</Text>
<Text style={styles.message}>
{this.state.error?.message || 'An unexpected error occurred'}
</Text>
<TouchableOpacity style={styles.button} onPress={this.handleReset}>
<Text style={styles.buttonText}>Try Again</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
return this.props.children;
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
content: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
title: {
fontSize: 24,
fontWeight: 'bold',
color: '#333',
marginTop: 20,
marginBottom: 10,
},
message: {
fontSize: 16,
color: '#666',
textAlign: 'center',
marginBottom: 30,
},
button: {
backgroundColor: '#3b5998',
paddingVertical: 12,
paddingHorizontal: 30,
borderRadius: 25,
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
});
export default ErrorBoundary;Troubleshooting
Common Issues
-
Deep Linking Not Working
- Verify URL scheme in iOS Info.plist and Android manifest
- Check AppDelegate.mm (iOS) and MainActivity (Android) for proper handling
- Test with
npx uri-scheme open "com.yourcompany.app://oauth/callback" --ios
-
Token Refresh Failing
- Ensure offline_access scope is included
- Check refresh token expiration in Keycloak
- Verify secure storage is working properly
-
Android Release Build Issues
- Add ProGuard rules for AppAuth and JWT libraries
- Ensure manifest placeholders are set in build.gradle
- Check for certificate pinning in release mode
Debug Utilities
// src/utils/debug.ts
import { LogBox } from 'react-native';
export class DebugUtils {
static enableDebugMode() {
if (__DEV__) {
// Enable network debugging
global.XMLHttpRequest = global.originalXMLHttpRequest || global.XMLHttpRequest;
// Log all network requests
const originalFetch = global.fetch;
global.fetch = async (...args) => {
console.log('[Network]', args[0], args[1]?.method || 'GET');
const response = await originalFetch(...args);
console.log('[Network Response]', response.status);
return response;
};
// Ignore specific warnings
LogBox.ignoreLogs([
'Non-serializable values were found in the navigation state',
]);
}
}
static logAuthState(authState: any) {
console.log('=== Auth State ===');
console.log('Authenticated:', authState.hasLoggedIn);
console.log('Access Token:', authState.accessToken?.substring(0, 20) + '...');
console.log('Expires At:', authState.accessTokenExpirationDate);
console.log('User Info:', authState.userInfo);
}
static async testDeepLink(url: string) {
const { Linking } = require('react-native');
try {
const canOpen = await Linking.canOpenURL(url);
console.log(`Can open ${url}:`, canOpen);
if (canOpen) {
await Linking.openURL(url);
}
} catch (error) {
console.error('Deep link test failed:', error);
}
}
}Next Steps
- Configure Security Settings - Add extra security layers including MFA options
- Set Up Applications - Configure your React Native app client settings
- User Management - Manage users and their access
- Explore Other Integrations - See integration guides for other platforms