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-ts

Or using @react-keycloak/web:

npm install @react-keycloak/web keycloak-js

2. 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

  1. Redirect Loop

    • Verify redirect URIs in Skycloak client configuration
    • Check for trailing slashes in URLs
    • Ensure cookies are enabled
  2. CORS Errors

    • Add your React app origin to Skycloak client web origins
    • Configure proper CORS headers on your API
  3. 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);
}

Next Steps