Chuyển đến nội dung chính

Lesson 21: Integrating React, Angular and Node.js

Integrate Keycloak with frontend (React uses keycloak-js or react-oidc-context, Angular uses angular-auth-oidc-client), auth state management, silent token refresh, protected routes, role-based UI rendering. Backend Node.js/Express with passport-keycloak-connect or jose JWT verification, middleware authentication, API Gateway pattern.

🔒 DevSecOps — Lesson 21 Lesson 21: Integrating React, Angular and Node.js

Keycloak from Basic to Advanced

Part 6: Integrating practical applications

xdev.asia

1. Frontend and Node.js integration overview

When integrating Keycloak with frontend applications, we use Authorization Code Flow with PKCE (Proof Key for Code Exchange) — the most standard and secure flow for Single Page Applications (SPA) and public clients.

┌───────────┐     ┌──────────┐     ┌──────────────┐
│  React /  │────▶│ Keycloak │     │  Node.js     │
│  Angular  │     │  Server  │     │  Backend API │
│  (SPA)    │     └────┬─────┘     └──────┬───────┘
│           │          │                  │
│  1. Login │──────────▶ 2. Auth Code     │
│           │◀──────────  + PKCE          │
│  3. Exchange code    │                  │
│     for tokens ─────▶│                  │
│           │◀──── 4. Access Token        │
│           │          + Refresh Token    │
│  5. API call with Bearer token ────────▶│
│           │◀──── 6. Protected response  │
└───────────┘                             │
                                          │
            7. Validate JWT ──────────────▶│
               (jwks-uri)                 │
PlatformLibraryApproach
Reactreact-oidc-context (recommended) / keycloak-jsOIDC Client / Keycloak Adapter
Angularangular-auth-oidc-clientOIDC Client
Node.jsjose / passport-keycloak-connectJWT Verification / Passport Strategy

2. React + Keycloak Integration

react-oidc-context is a modern OIDC library, based on oidc-client-ts, recommended for new React applications:

# Cài đặt dependencies
npm install react-oidc-context oidc-client-ts

2.1.1 AuthProvider Setup

// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { AuthProvider } from 'react-oidc-context';
import App from './App';

const oidcConfig = {
  authority: 'http://localhost:8080/realms/my-realm',
  client_id: 'my-react-client',
  redirect_uri: window.location.origin + '/callback',
  post_logout_redirect_uri: window.location.origin,
  // PKCE code flow (default và recommended)
  response_type: 'code',
  scope: 'openid profile email',
  // Silent refresh
  automaticSilentRenew: true,
  // Lưu tokens trong sessionStorage (an toàn hơn localStorage)
  userStore: undefined, // Mặc định sessionStorage
};

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <AuthProvider {...oidcConfig}>
      <App />
    </AuthProvider>
  </React.StrictMode>
);

2.1.2 useAuth Hook Usage

// src/components/UserInfo.tsx
import { useAuth } from 'react-oidc-context';

function UserInfo() {
  const auth = useAuth();

  if (auth.isLoading) {
    return <div>Loading...</div>;
  }

  if (auth.error) {
    return <div>Error: {auth.error.message}</div>;
  }

  if (!auth.isAuthenticated) {
    return (
      <div>
        <p>Bạn chưa đăng nhập</p>
        <button onClick={() => auth.signinRedirect()}>
          Đăng nhập
        </button>
      </div>
    );
  }

  return (
    <div>
      <h2>Welcome, {auth.user?.profile.preferred_username}</h2>
      <p>Email: {auth.user?.profile.email}</p>
      <p>Token expires in: {auth.user?.expires_in}s</p>
      <button onClick={() => auth.removeUser()}>
        Đăng xuất
      </button>
    </div>
  );
}

export default UserInfo;

2.1.3 API calls with Access Token

// src/hooks/useApi.ts
import { useAuth } from 'react-oidc-context';
import { useCallback } from 'react';

export function useApi() {
  const auth = useAuth();

  const fetchWithAuth = useCallback(
    async (url: string, options: RequestInit = {}) => {
      if (!auth.user?.access_token) {
        throw new Error('Not authenticated');
      }

      const response = await fetch(url, {
        ...options,
        headers: {
          ...options.headers,
          Authorization: `Bearer ${auth.user.access_token}`,
          'Content-Type': 'application/json',
        },
      });

      if (response.status === 401) {
        // Token expired, trigger silent renew
        await auth.signinSilent();
        // Retry request
        return fetch(url, {
          ...options,
          headers: {
            ...options.headers,
            Authorization: `Bearer ${auth.user.access_token}`,
            'Content-Type': 'application/json',
          },
        });
      }

      return response;
    },
    [auth]
  );

  return { fetchWithAuth };
}

// Sử dụng trong component
function UserList() {
  const { fetchWithAuth } = useApi();
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetchWithAuth('http://localhost:8081/api/users')
      .then(res => res.json())
      .then(data => setUsers(data));
  }, [fetchWithAuth]);

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.username}</li>
      ))}
    </ul>
  );
}

2.1.4 Protected Routes

// src/components/PrivateRoute.tsx
import { useAuth } from 'react-oidc-context';
import { Navigate } from 'react-router-dom';

interface PrivateRouteProps {
  children: React.ReactNode;
  requiredRoles?: string[];
}

function PrivateRoute({ children, requiredRoles = [] }: PrivateRouteProps) {
  const auth = useAuth();

  if (auth.isLoading) {
    return <div>Loading...</div>;
  }

  if (!auth.isAuthenticated) {
    // Redirect to Keycloak login
    auth.signinRedirect();
    return <div>Redirecting to login...</div>;
  }

  // Kiểm tra roles nếu được yêu cầu
  if (requiredRoles.length > 0) {
    const userRoles = getUserRoles(auth.user);
    const hasRequiredRole = requiredRoles.some(role =>
      userRoles.includes(role)
    );

    if (!hasRequiredRole) {
      return <Navigate to="/unauthorized" replace />;
    }
  }

  return <>{children}</>;
}

// Extract roles từ Keycloak token claims
function getUserRoles(user: any): string[] {
  if (!user?.profile) return [];

  const realmRoles = user.profile.realm_access?.roles || [];
  const clientRoles =
    user.profile.resource_access?.['my-react-client']?.roles || [];

  return [...realmRoles, ...clientRoles];
}

export default PrivateRoute;
// src/App.tsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import PrivateRoute from './components/PrivateRoute';
import Home from './pages/Home';
import Dashboard from './pages/Dashboard';
import AdminPanel from './pages/AdminPanel';
import Unauthorized from './pages/Unauthorized';

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Home />} />

        {/* Protected route - chỉ cần authenticated */}
        <Route path="/dashboard" element={
          <PrivateRoute>
            <Dashboard />
          </PrivateRoute>
        } />

        {/* Protected route - yêu cầu role ADMIN */}
        <Route path="/admin" element={
          <PrivateRoute requiredRoles={['ADMIN']}>
            <AdminPanel />
          </PrivateRoute>
        } />

        <Route path="/unauthorized" element={<Unauthorized />} />
      </Routes>
    </BrowserRouter>
  );
}

export default App;

2.1.5 Role-based UI Rendering

// src/components/RoleBasedContent.tsx
import { useAuth } from 'react-oidc-context';

function getUserRoles(user: any): string[] {
  if (!user?.profile) return [];
  return user.profile.realm_access?.roles || [];
}

function hasRole(user: any, role: string): boolean {
  return getUserRoles(user).includes(role);
}

function Navigation() {
  const auth = useAuth();
  const user = auth.user;

  return (
    <nav>
      <ul>
        <li><a href="/">Home</a></li>

        {auth.isAuthenticated && (
          <li><a href="/dashboard">Dashboard</a></li>
        )}

        {/* Chỉ hiển thị cho ADMIN */}
        {hasRole(user, 'ADMIN') && (
          <>
            <li><a href="/admin">Admin Panel</a></li>
            <li><a href="/admin/users">User Management</a></li>
          </>
        )}

        {/* Chỉ hiển thị cho EDITOR */}
        {hasRole(user, 'EDITOR') && (
          <li><a href="/content">Content Management</a></li>
        )}
      </ul>
    </nav>
  );
}

export default Navigation;

2.2 Approach 2: keycloak-js Adapter

keycloak-js is the official adapter from Keycloak. Still works fine but react-oidc-context is preferred for new projects because it is more OIDC standard:

npm install keycloak-js
// src/keycloak.ts
import Keycloak from 'keycloak-js';

const keycloak = new Keycloak({
  url: 'http://localhost:8080',
  realm: 'my-realm',
  clientId: 'my-react-client',
});

export default keycloak;
// src/components/KeycloakProvider.tsx
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import Keycloak from 'keycloak-js';
import keycloak from '../keycloak';

interface KeycloakContextType {
  keycloak: Keycloak;
  initialized: boolean;
  authenticated: boolean;
}

const KeycloakContext = createContext<KeycloakContextType>({
  keycloak,
  initialized: false,
  authenticated: false,
});

export function useKeycloak() {
  return useContext(KeycloakContext);
}

interface KeycloakProviderProps {
  children: ReactNode;
}

export function KeycloakProvider({ children }: KeycloakProviderProps) {
  const [initialized, setInitialized] = useState(false);
  const [authenticated, setAuthenticated] = useState(false);

  useEffect(() => {
    keycloak
      .init({
        onLoad: 'check-sso',       // hoặc 'login-required'
        pkceMethod: 'S256',         // PKCE method
        checkLoginIframe: false,    // Tắt iframe check (recommended)
        silentCheckSsoRedirectUri:
          window.location.origin + '/silent-check-sso.html',
      })
      .then((auth) => {
        setAuthenticated(auth);
        setInitialized(true);

        // Setup token refresh
        if (auth) {
          setInterval(() => {
            keycloak
              .updateToken(70)  // Refresh nếu token hết hạn trong 70s
              .catch(() => {
                console.warn('Token refresh failed, logging out');
                keycloak.logout();
              });
          }, 60000); // Check mỗi 60 giây
        }
      })
      .catch((err) => {
        console.error('Keycloak init failed:', err);
        setInitialized(true);
      });
  }, []);

  if (!initialized) {
    return <div>Loading Keycloak...</div>;
  }

  return (
    <KeycloakContext.Provider value={{ keycloak, initialized, authenticated }}>
      {children}
    </KeycloakContext.Provider>
  );
}
// Sử dụng keycloak-js trong component
import { useKeycloak } from './KeycloakProvider';

function Profile() {
  const { keycloak, authenticated } = useKeycloak();

  if (!authenticated) {
    return <button onClick={() => keycloak.login()}>Login</button>;
  }

  return (
    <div>
      <h2>{keycloak.tokenParsed?.preferred_username}</h2>
      <p>Email: {keycloak.tokenParsed?.email}</p>

      {/* Role-based rendering */}
      {keycloak.hasRealmRole('ADMIN') && (
        <p>🔑 You are an Admin</p>
      )}
      {keycloak.hasResourceRole('editor', 'my-react-client') && (
        <p>✏️ You have editor access</p>
      )}

      <button onClick={() => keycloak.logout()}>Logout</button>
    </div>
  );
}

2.3 Keycloak Client Configuration cho SPA

Create client in Keycloak for React/Angular SPA:

Client Settings:
  Client ID:           my-react-client
  Client Protocol:     openid-connect
  Access Type:         public (SPA không có client_secret)
  Standard Flow:       ON (Authorization Code Flow)
  Direct Access:       OFF (không dùng cho SPA)
  Implicit Flow:       OFF (deprecated, dùng PKCE thay thế)

  Valid Redirect URIs:
    - http://localhost:3000/*
    - http://localhost:4200/*

  Valid Post Logout Redirect URIs:
    - http://localhost:3000/*
    - http://localhost:4200/*

  Web Origins:
    - http://localhost:3000
    - http://localhost:4200
    - + (cho phép tất cả origin từ redirect URIs)

  Advanced Settings:
    Proof Key for Code Exchange (PKCE): S256
    Access Token Lifespan: 5 minutes
    Refresh Token: Enable

3. Angular + Keycloak Integration

3.1 Install angular-auth-oidc-client

npm install angular-auth-oidc-client

3.2 OIDC Module Configuration

// src/app/app.config.ts (Angular 17+ standalone)
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideAuth, LogLevel } from 'angular-auth-oidc-client';
import { routes } from './app.routes';
import { authInterceptor } from './auth/auth.interceptor';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(withInterceptors([authInterceptor])),
    provideAuth({
      config: {
        authority: 'http://localhost:8080/realms/my-realm',
        redirectUrl: window.location.origin + '/callback',
        postLogoutRedirectUri: window.location.origin,
        clientId: 'my-angular-client',
        scope: 'openid profile email',
        responseType: 'code',
        // Silent renew
        silentRenew: true,
        silentRenewUrl: window.location.origin + '/silent-renew.html',
        useRefreshToken: true,
        // Logging
        logLevel: LogLevel.Debug,
        // Auto login
        autoUserInfo: true,
        // Token renewal
        renewTimeBeforeTokenExpiresInSec: 30,
      },
    }),
  ],
};

3.3 Auth Service

// src/app/auth/auth.service.ts
import { Injectable, inject } from '@angular/core';
import { OidcSecurityService } from 'angular-auth-oidc-client';
import { map } from 'rxjs/operators';

@Injectable({ providedIn: 'root' })
export class AuthService {
  private oidcService = inject(OidcSecurityService);

  isAuthenticated$ = this.oidcService.isAuthenticated$.pipe(
    map((result) => result.isAuthenticated)
  );

  userData$ = this.oidcService.userData$;

  login(): void {
    this.oidcService.authorize();
  }

  logout(): void {
    this.oidcService
      .logoff()
      .subscribe((result) => console.log('Logged out', result));
  }

  getAccessToken(): string {
    let token = '';
    this.oidcService
      .getAccessToken()
      .subscribe((t) => (token = t));
    return token;
  }

  hasRole(role: string): boolean {
    let hasRole = false;
    this.userData$.subscribe((userData) => {
      const realmRoles = userData?.userData?.realm_access?.roles || [];
      hasRole = realmRoles.includes(role);
    });
    return hasRole;
  }

  getUserRoles(): string[] {
    let roles: string[] = [];
    this.userData$.subscribe((userData) => {
      roles = userData?.userData?.realm_access?.roles || [];
    });
    return roles;
  }
}

3.4 Auth Guard

// src/app/auth/auth.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { OidcSecurityService } from 'angular-auth-oidc-client';
import { map, take } from 'rxjs/operators';

export const authGuard: CanActivateFn = (route, state) => {
  const oidcService = inject(OidcSecurityService);
  const router = inject(Router);

  return oidcService.isAuthenticated$.pipe(
    take(1),
    map(({ isAuthenticated }) => {
      if (!isAuthenticated) {
        oidcService.authorize();
        return false;
      }

      // Kiểm tra required roles nếu có
      const requiredRoles = route.data?.['roles'] as string[];
      if (requiredRoles && requiredRoles.length > 0) {
        // Kiểm tra roles từ token
        const token = oidcService.getPayloadFromAccessToken();
        const userRoles: string[] =
          token?.realm_access?.roles || [];
        const hasRole = requiredRoles.some((role) =>
          userRoles.includes(role)
        );

        if (!hasRole) {
          router.navigate(['/unauthorized']);
          return false;
        }
      }

      return true;
    })
  );
};

3.5 HTTP Interceptor

// src/app/auth/auth.interceptor.ts
import { HttpInterceptorFn, HttpHandlerFn, HttpRequest } from '@angular/common/http';
import { inject } from '@angular/core';
import { OidcSecurityService } from 'angular-auth-oidc-client';
import { switchMap, take } from 'rxjs/operators';

export const authInterceptor: HttpInterceptorFn = (
  req: HttpRequest<unknown>,
  next: HttpHandlerFn
) => {
  const oidcService = inject(OidcSecurityService);

  // Chỉ thêm token cho API calls
  if (!req.url.includes('/api/')) {
    return next(req);
  }

  return oidcService.getAccessToken().pipe(
    take(1),
    switchMap((token) => {
      if (token) {
        const clonedReq = req.clone({
          setHeaders: {
            Authorization: `Bearer ${token}`,
          },
        });
        return next(clonedReq);
      }
      return next(req);
    })
  );
};

3.6 Routing Configuration

// src/app/app.routes.ts
import { Routes } from '@angular/router';
import { authGuard } from './auth/auth.guard';
import { AutoLoginPartialRoutesGuard } from 'angular-auth-oidc-client';

export const routes: Routes = [
  {
    path: '',
    loadComponent: () =>
      import('./pages/home/home.component').then((m) => m.HomeComponent),
  },
  {
    path: 'callback',
    loadComponent: () =>
      import('./pages/callback/callback.component').then(
        (m) => m.CallbackComponent
      ),
  },
  {
    path: 'dashboard',
    loadComponent: () =>
      import('./pages/dashboard/dashboard.component').then(
        (m) => m.DashboardComponent
      ),
    canActivate: [authGuard],
  },
  {
    path: 'admin',
    loadComponent: () =>
      import('./pages/admin/admin.component').then(
        (m) => m.AdminComponent
      ),
    canActivate: [authGuard],
    data: { roles: ['ADMIN'] },
  },
  {
    path: 'unauthorized',
    loadComponent: () =>
      import('./pages/unauthorized/unauthorized.component').then(
        (m) => m.UnauthorizedComponent
      ),
  },
];

3.7 Component uses Auth

// src/app/pages/dashboard/dashboard.component.ts
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { OidcSecurityService } from 'angular-auth-oidc-client';
import { AuthService } from '../../auth/auth.service';

@Component({
  selector: 'app-dashboard',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div *ngIf="isAuthenticated">
      <h2>Dashboard</h2>
      <p>Username: {{ userData?.preferred_username }}</p>
      <p>Email: {{ userData?.email }}</p>
      <p>Roles: {{ roles.join(', ') }}</p>

      <div *ngIf="authService.hasRole('ADMIN')">
        <h3>Admin Section</h3>
        <p>Nội dung chỉ dành cho Admin</p>
      </div>

      <button (click)="authService.logout()">Logout</button>
    </div>
  `,
})
export class DashboardComponent implements OnInit {
  authService = inject(AuthService);
  private oidcService = inject(OidcSecurityService);

  isAuthenticated = false;
  userData: any = null;
  roles: string[] = [];

  ngOnInit(): void {
    this.oidcService.isAuthenticated$.subscribe(
      (result) => (this.isAuthenticated = result.isAuthenticated)
    );

    this.oidcService.userData$.subscribe((result) => {
      this.userData = result.userData;
      this.roles = this.userData?.realm_access?.roles || [];
    });
  }
}

4. Node.js/Express + Keycloak Integration

Use library jose to verify JWT tokens — lightweight approach, not dependent on framework-specific adapter:

npm install jose express
npm install -D @types/express typescript
// src/middleware/auth.ts
import { createRemoteJWKSet, jwtVerify, JWTPayload } from 'jose';
import { Request, Response, NextFunction } from 'express';

// Keycloak configuration
const KEYCLOAK_URL = process.env.KEYCLOAK_URL || 'http://localhost:8080';
const REALM = process.env.KEYCLOAK_REALM || 'my-realm';
const ISSUER = `${KEYCLOAK_URL}/realms/${REALM}`;
const JWKS_URI = `${ISSUER}/protocol/openid-connect/certs`;

// Tạo JWKS client (tự cache keys)
const JWKS = createRemoteJWKSet(new URL(JWKS_URI));

// Extended Request type
interface AuthenticatedRequest extends Request {
  user?: {
    sub: string;
    username: string;
    email: string;
    roles: string[];
    clientRoles: string[];
  };
}

// JWT verification middleware
export async function authenticate(
  req: AuthenticatedRequest,
  res: Response,
  next: NextFunction
): Promise<void> {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    res.status(401).json({ error: 'Missing or invalid Authorization header' });
    return;
  }

  const token = authHeader.substring(7);

  try {
    const { payload } = await jwtVerify(token, JWKS, {
      issuer: ISSUER,
      // Optionally validate audience
      // audience: 'my-client',
    });

    // Extract user info from JWT claims
    req.user = {
      sub: payload.sub || '',
      username: (payload as any).preferred_username || '',
      email: (payload as any).email || '',
      roles: extractRealmRoles(payload),
      clientRoles: extractClientRoles(payload, 'my-client'),
    };

    next();
  } catch (err) {
    console.error('JWT verification failed:', err);
    res.status(401).json({ error: 'Invalid or expired token' });
  }
}

// Extract realm roles từ JWT
function extractRealmRoles(payload: JWTPayload): string[] {
  const realmAccess = (payload as any).realm_access;
  return realmAccess?.roles || [];
}

// Extract client roles từ JWT
function extractClientRoles(
  payload: JWTPayload,
  clientId: string
): string[] {
  const resourceAccess = (payload as any).resource_access;
  return resourceAccess?.[clientId]?.roles || [];
}

// Role check middleware factory
export function requireRole(...roles: string[]) {
  return (
    req: AuthenticatedRequest,
    res: Response,
    next: NextFunction
  ): void => {
    if (!req.user) {
      res.status(401).json({ error: 'Not authenticated' });
      return;
    }

    const userRoles = [...req.user.roles, ...req.user.clientRoles];
    const hasRole = roles.some((role) => userRoles.includes(role));

    if (!hasRole) {
      res.status(403).json({
        error: 'Forbidden',
        message: `Required roles: ${roles.join(', ')}`,
      });
      return;
    }

    next();
  };
}

4.2 Express Application

// src/app.ts
import express from 'express';
import cors from 'cors';
import { authenticate, requireRole } from './middleware/auth';

const app = express();

// Middleware
app.use(cors({
  origin: ['http://localhost:3000', 'http://localhost:4200'],
  credentials: true,
}));
app.use(express.json());

// ========== Public Routes ==========

app.get('/api/public/health', (req, res) => {
  res.json({ status: 'UP' });
});

// ========== Protected Routes ==========

// Tất cả routes dưới đây yêu cầu authentication
app.use('/api', authenticate);

app.get('/api/me', (req: any, res) => {
  res.json({
    username: req.user.username,
    email: req.user.email,
    roles: req.user.roles,
  });
});

// User routes
app.get('/api/users', requireRole('USER', 'ADMIN'), (req, res) => {
  res.json({
    message: 'User list',
    users: [
      { id: 1, name: 'User 1' },
      { id: 2, name: 'User 2' },
    ],
  });
});

// Admin routes
app.get(
  '/api/admin/dashboard',
  requireRole('ADMIN'),
  (req: any, res) => {
    res.json({
      message: 'Admin Dashboard',
      adminUser: req.user.username,
    });
  }
);

app.post(
  '/api/admin/users',
  requireRole('ADMIN'),
  (req: any, res) => {
    res.json({
      message: 'User created',
      createdBy: req.user.username,
      user: req.body,
    });
  }
);

// ========== Start Server ==========

const PORT = process.env.PORT || 8081;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

export default app;

4.3 Approach 2: passport-keycloak-connect

If you have used Passport.js in your project, you can use passport-keycloak-connect:

npm install passport passport-keycloak-connect express-session
npm install -D @types/passport @types/express-session
// src/passport-setup.ts
import passport from 'passport';
import KeycloakStrategy from 'passport-keycloak-connect';
import session from 'express-session';
import express from 'express';

const app = express();

// Session configuration (required for Passport)
app.use(session({
  secret: process.env.SESSION_SECRET || 'your-session-secret',
  resave: false,
  saveUninitialized: false,
  cookie: { secure: false }, // true cho HTTPS
}));

app.use(passport.initialize());
app.use(passport.session());

// Keycloak strategy
const keycloakConfig = {
  host: 'http://localhost:8080',
  realm: 'my-realm',
  clientId: 'my-node-client',
  clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
  callbackURL: '/auth/callback',
};

passport.use('keycloak', new KeycloakStrategy(keycloakConfig,
  (accessToken: string, refreshToken: string, profile: any, done: Function) => {
    // Profile chứa user info từ Keycloak
    return done(null, {
      id: profile.id,
      username: profile.username,
      email: profile.email,
      roles: profile.realm_access?.roles || [],
    });
  }
));

passport.serializeUser((user: any, done) => done(null, user));
passport.deserializeUser((user: any, done) => done(null, user));

// Routes
app.get('/auth/login',
  passport.authenticate('keycloak', { scope: ['openid', 'profile', 'email'] })
);

app.get('/auth/callback',
  passport.authenticate('keycloak', { failureRedirect: '/login-failed' }),
  (req, res) => {
    res.redirect('/dashboard');
  }
);

app.get('/api/me',
  (req, res, next) => {
    if (!req.isAuthenticated()) {
      return res.status(401).json({ error: 'Not authenticated' });
    }
    next();
  },
  (req, res) => {
    res.json(req.user);
  }
);

5. Silent Token Refresh

Token refresh is important to ensure a seamless user experience. There are 2 main methods:

5.1 Refresh Token Rotation

Client ──── Access Token (5 min) ────▶ API Server
              │                            │
              │ Token expired (401)         │
              │◀───────────────────────────│
              │                            │
Client ──── Refresh Token ──────────▶ Keycloak
              │                            │
              │ New Access Token            │
              │ + New Refresh Token         │
              │◀───────────────────────────│
              │                            │
Client ──── New Access Token ────────▶ API Server

5.2 react-oidc-context Auto Refresh

// Đã bật trong config
const oidcConfig = {
  // ...
  automaticSilentRenew: true,
};

// Lắng nghe sự kiện token refresh
import { useAuth } from 'react-oidc-context';

function TokenMonitor() {
  const auth = useAuth();

  useEffect(() => {
    // Sự kiện khi token được renew thành công
    return auth.events.addAccessTokenExpiring(() => {
      console.log('Token is about to expire, renewing...');
    });
  }, [auth.events]);

  useEffect(() => {
    return auth.events.addAccessTokenExpired(() => {
      console.log('Token expired, signing in silently...');
      auth.signinSilent().catch(() => {
        // Silent renew failed, redirect to login
        auth.signinRedirect();
      });
    });
  }, [auth.events, auth]);

  return null;
}

5.3 Silent Check SSO HTML

Create file public/silent-check-sso.html for silent SSO check:

<!DOCTYPE html>
<html>
<head>
  <title>Silent Check SSO</title>
</head>
<body>
  <script>
    parent.postMessage(location.href, location.origin);
  </script>
</body>
</html>

6. Keycloak Client Settings Reference

Refer to Keycloak client configuration for application types:

SettingSPA (React/Angular)Node.js Backend
Client Protocolopenid-connectopenid-connect
Access Typepublicconfidential
Standard FlowONON (if web app)
Direct AccessOFFOFF
Service AccountsOFFON (if needed)
PKCES256N/A
Valid Redirect URIshttp://localhost:3000/*http://localhost:8081/*
Web Originshttp://localhost:3000+

7. Summary

PlatformLibraryAdvantagesNotes
Reactreact-oidc-contextOIDC standards, API hooks, auto refreshNeed to configure redirect URIs correctly
Reactkeycloak-jsOfficial adapter, full APITightly coupled with Keycloak
Angularangular-auth-oidc-clientAngular-native, Guards, InterceptorsConfig more complex than React
Node.jsjoseLightweight, no dependency, standardNeed to implement middleware yourself
Node.jspassport-keycloak-connectIntegrate Passport.js ecosystemNeed session management

In the next article, we will learn how to integrate Keycloak with API Gateway (Nginx, Kong, Traefik) and microservices architecture.