React Integration
React Integration
This guide covers how to integrate Skycloak authentication into React applications using modern OAuth2/OIDC libraries and best practices.
Prerequisites
- React 17+ application
- Node.js 14+
- Skycloak cluster with configured realm and client
- Basic understanding of React hooks and context
Quick Start
1. Install Dependencies
Using react-oidc-context (recommended):
npm install react-oidc-context oidc-client-tsOr using @react-keycloak/web:
npm install @react-keycloak/web keycloak-js2. Configure OIDC Provider
Create src/auth/authConfig.js:
export const oidcConfig = {
authority: 'https://your-cluster-id.app.skycloak.io/realms/your-realm',
client_id: 'your-react-app',
redirect_uri: window.location.origin,
post_logout_redirect_uri: window.location.origin,
response_type: 'code',
scope: 'openid profile email',
automaticSilentRenew: true,
includeIdTokenInSilentRenew: true,
};3. Setup Auth Provider
Wrap your app with the auth provider:
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import { AuthProvider } from 'react-oidc-context';
import App from './App';
import { oidcConfig } from './auth/authConfig';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<AuthProvider {...oidcConfig}>
<App />
</AuthProvider>
</React.StrictMode>
);4. Create Protected Routes
// src/components/ProtectedRoute.jsx
import { useAuth } from 'react-oidc-context';
import { Navigate } from 'react-router-dom';
export function ProtectedRoute({ children }) {
const auth = useAuth();
if (auth.isLoading) {
return <div>Loading...</div>;
}
if (auth.error) {
return <div>Error: {auth.error.message}</div>;
}
if (!auth.isAuthenticated) {
return <Navigate to="/login" replace />;
}
return children;
}5. Implement Login/Logout
// src/components/AuthButtons.jsx
import { useAuth } from 'react-oidc-context';
export function AuthButtons() {
const auth = useAuth();
if (auth.isLoading) {
return <button disabled>Loading...</button>;
}
if (auth.error) {
return <div>Error: {auth.error.message}</div>;
}
if (auth.isAuthenticated) {
return (
<div>
<span>Hello {auth.user?.profile.preferred_username}!</span>
<button onClick={() => auth.signoutRedirect()}>Logout</button>
</div>
);
}
return <button onClick={() => auth.signinRedirect()}>Login with Skycloak</button>;
}Advanced Authentication
Custom Auth Hook
// src/hooks/useSkycloakAuth.js
import { useAuth } from 'react-oidc-context';
import { useCallback, useMemo } from 'react';
export function useSkycloakAuth() {
const auth = useAuth();
const hasRole = useCallback(
(role) => {
if (!auth.user) return false;
const roles = auth.user.profile.realm_access?.roles || [];
return roles.includes(role);
},
[auth.user]
);
const hasAnyRole = useCallback(
(...roles) => {
return roles.some((role) => hasRole(role));
},
[hasRole]
);
const hasAllRoles = useCallback(
(...roles) => {
return roles.every((role) => hasRole(role));
},
[hasRole]
);
const getToken = useCallback(async () => {
if (!auth.user) return null;
// Check if token needs refresh
if (auth.user.expired) {
try {
await auth.signinSilent();
} catch (error) {
console.error('Failed to refresh token:', error);
await auth.signinRedirect();
}
}
return auth.user.access_token;
}, [auth]);
const userInfo = useMemo(() => {
if (!auth.user) return null;
return {
id: auth.user.profile.sub,
username: auth.user.profile.preferred_username,
email: auth.user.profile.email,
firstName: auth.user.profile.given_name,
lastName: auth.user.profile.family_name,
roles: auth.user.profile.realm_access?.roles || [],
groups: auth.user.profile.groups || [],
};
}, [auth.user]);
return {
...auth,
hasRole,
hasAnyRole,
hasAllRoles,
getToken,
userInfo,
};
}Role-Based Component
// src/components/RoleGuard.jsx
import { useSkycloakAuth } from '../hooks/useSkycloakAuth';
export function RoleGuard({ roles, fallback = null, children }) {
const { hasAnyRole } = useSkycloakAuth();
if (!hasAnyRole(...roles)) {
return fallback || <div>Access Denied</div>;
}
return children;
}
// Usage
<RoleGuard roles={['admin', 'moderator']}>
<AdminPanel />
</RoleGuard>;Protected API Calls
// src/services/apiClient.js
import axios from 'axios';
export function createApiClient(getToken) {
const client = axios.create({
baseURL: process.env.REACT_APP_API_URL,
});
// Request interceptor to add token
client.interceptors.request.use(
async (config) => {
const token = await getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor for token refresh
client.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
// Token might be expired, trigger refresh
window.dispatchEvent(new Event('token-expired'));
}
return Promise.reject(error);
}
);
return client;
}
// Usage in component
import { useEffect, useState } from 'react';
import { useSkycloakAuth } from '../hooks/useSkycloakAuth';
import { createApiClient } from '../services/apiClient';
export function UserProfile() {
const { getToken } = useSkycloakAuth();
const [profile, setProfile] = useState(null);
useEffect(() => {
const apiClient = createApiClient(getToken);
apiClient
.get('/api/profile')
.then((response) => setProfile(response.data))
.catch((error) => console.error('Failed to fetch profile:', error));
}, [getToken]);
return <div>{profile && <pre>{JSON.stringify(profile, null, 2)}</pre>}</div>;
}State Management Integration
Redux Integration
// src/store/authSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const refreshToken = createAsyncThunk('auth/refreshToken', async (_, { extra }) => {
const { authService } = extra;
const user = await authService.signinSilent();
return {
accessToken: user.access_token,
idToken: user.id_token,
profile: user.profile,
};
});
const authSlice = createSlice({
name: 'auth',
initialState: {
isAuthenticated: false,
user: null,
loading: false,
error: null,
},
reducers: {
loginSuccess: (state, action) => {
state.isAuthenticated = true;
state.user = action.payload;
state.error = null;
},
logout: (state) => {
state.isAuthenticated = false;
state.user = null;
},
},
extraReducers: (builder) => {
builder
.addCase(refreshToken.pending, (state) => {
state.loading = true;
})
.addCase(refreshToken.fulfilled, (state, action) => {
state.loading = false;
state.user = action.payload;
})
.addCase(refreshToken.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message;
});
},
});
export const { loginSuccess, logout } = authSlice.actions;
export default authSlice.reducer;Context API Integration
// src/context/AuthContext.jsx
import React, { createContext, useContext, useEffect, useState } from 'react';
import { useAuth } from 'react-oidc-context';
const AuthContext = createContext(null);
export function AuthContextProvider({ children }) {
const oidcAuth = useAuth();
const [permissions, setPermissions] = useState([]);
useEffect(() => {
if (oidcAuth.user) {
// Fetch user permissions from your API
fetchUserPermissions(oidcAuth.user.access_token).then(setPermissions).catch(console.error);
}
}, [oidcAuth.user]);
const can = (permission) => {
return permissions.includes(permission);
};
const value = {
...oidcAuth,
permissions,
can,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export const useAuthContext = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuthContext must be used within AuthContextProvider');
}
return context;
};Silent Authentication
Configure Silent Renew
// src/components/SilentRenew.jsx
import { useEffect } from 'react';
import { useAuth } from 'react-oidc-context';
export function SilentRenew() {
const auth = useAuth();
useEffect(() => {
const handleTokenExpiring = () => {
console.log('Token expiring, attempting silent renew...');
};
const handleTokenExpired = async () => {
console.log('Token expired, attempting silent renew...');
try {
await auth.signinSilent();
} catch (error) {
console.error('Silent renew failed:', error);
// Redirect to login
auth.signinRedirect();
}
};
// Listen for token events
if (auth.events) {
auth.events.addAccessTokenExpiring(handleTokenExpiring);
auth.events.addAccessTokenExpired(handleTokenExpired);
return () => {
auth.events.removeAccessTokenExpiring(handleTokenExpiring);
auth.events.removeAccessTokenExpired(handleTokenExpired);
};
}
}, [auth]);
return null;
}Callback Page for Silent Renew
// src/pages/SilentCallback.jsx
import { useEffect } from 'react';
import { useAuth } from 'react-oidc-context';
export function SilentCallback() {
const auth = useAuth();
useEffect(() => {
auth.signinSilentCallback().catch((error) => console.error('Silent callback error:', error));
}, [auth]);
return <div>Processing silent authentication...</div>;
}Multi-Tenant Support
// src/auth/MultiTenantAuth.jsx
import { AuthProvider } from 'react-oidc-context';
import { useMemo } from 'react';
export function MultiTenantAuthProvider({ tenant, children }) {
const config = useMemo(
() => ({
authority: `https://${tenant}.skycloak.io/realms/${tenant}`,
client_id: 'multi-tenant-app',
redirect_uri: `${window.location.origin}/callback`,
scope: 'openid profile email',
// Store tenant in session storage
userStore: new WebStorageStateStore({
store: window.sessionStorage,
prefix: tenant,
}),
}),
[tenant]
);
return <AuthProvider {...config}>{children}</AuthProvider>;
}Testing
Mock Auth Provider
// src/test-utils/MockAuthProvider.jsx
import React from 'react';
import { AuthProvider } from 'react-oidc-context';
export function MockAuthProvider({ user, isAuthenticated = true, children }) {
const mockAuth = {
isAuthenticated,
isLoading: false,
user: user || {
profile: {
sub: 'test-user-123',
preferred_username: 'testuser',
email: '[email protected]',
realm_access: {
roles: ['user', 'admin'],
},
},
access_token: 'mock-access-token',
},
signinRedirect: jest.fn(),
signoutRedirect: jest.fn(),
signinSilent: jest.fn(),
};
return <AuthProvider {...mockAuth}>{children}</AuthProvider>;
}Component Tests
// src/components/__tests__/ProtectedRoute.test.jsx
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { ProtectedRoute } from '../ProtectedRoute';
import { MockAuthProvider } from '../../test-utils/MockAuthProvider';
describe('ProtectedRoute', () => {
it('renders children when authenticated', () => {
render(
<MockAuthProvider isAuthenticated={true}>
<MemoryRouter>
<ProtectedRoute>
<div>Protected Content</div>
</ProtectedRoute>
</MemoryRouter>
</MockAuthProvider>
);
expect(screen.getByText('Protected Content')).toBeInTheDocument();
});
it('redirects when not authenticated', () => {
render(
<MockAuthProvider isAuthenticated={false}>
<MemoryRouter>
<ProtectedRoute>
<div>Protected Content</div>
</ProtectedRoute>
</MemoryRouter>
</MockAuthProvider>
);
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
});Production Optimizations
Code Splitting for Auth
// src/App.jsx
import { lazy, Suspense } from 'react';
import { useAuth } from 'react-oidc-context';
const AuthenticatedApp = lazy(() => import('./AuthenticatedApp'));
const UnauthenticatedApp = lazy(() => import('./UnauthenticatedApp'));
export function App() {
const auth = useAuth();
return (
<Suspense fallback={<div>Loading...</div>}>
{auth.isAuthenticated ? <AuthenticatedApp /> : <UnauthenticatedApp />}
</Suspense>
);
}Performance Monitoring
// src/hooks/useAuthPerformance.js
import { useEffect } from 'react';
import { useAuth } from 'react-oidc-context';
export function useAuthPerformance() {
const auth = useAuth();
useEffect(() => {
if (window.performance && auth.user) {
const authTime = performance.now();
console.log(`Authentication completed in ${authTime}ms`);
// Send to analytics
if (window.gtag) {
window.gtag('event', 'timing_complete', {
name: 'authentication',
value: Math.round(authTime),
});
}
}
}, [auth.user]);
}Security Headers
Configure your web server or CDN to include security headers:
# nginx.conf
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https://*.skycloak.io; frame-src 'self' https://*.skycloak.io;" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;Troubleshooting
Common Issues
-
Redirect Loop
- Verify redirect URIs in Skycloak client configuration
- Check for trailing slashes in URLs
- Ensure cookies are enabled
-
CORS Errors
- Add your React app origin to Skycloak client web origins
- Configure proper CORS headers on your API
-
Token Refresh Failures
- Check refresh token expiration settings
- Verify silent renew iframe configuration
- Monitor browser console for blocked third-party cookies
Debug Mode
// Enable debug logging
import { Log } from 'oidc-client-ts';
if (process.env.NODE_ENV === 'development') {
Log.setLogger(console);
Log.setLevel(Log.DEBUG);
}