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

Bài 21: Tích hợp React, Angular và Node.js

Tích hợp Keycloak với frontend (React sử dụng keycloak-js hoặc react-oidc-context, Angular sử dụng angular-auth-oidc-client), quản lý auth state, silent token refresh, protected routes, role-based UI rendering. Backend Node.js/Express với passport-keycloak-connect hoặc jose JWT verification, middleware authentication, API Gateway pattern.

🔒 DevSecOps — Bài 21 Bài 21: Tích hợp React, Angular và Node.js

Keycloak từ Cơ bản đến Nâng cao

Phần 6: Tích hợp ứng dụng thực tế

xdev.asia

1. Tổng quan tích hợp Frontend và Node.js

Khi tích hợp Keycloak với frontend applications, chúng ta sử dụng Authorization Code Flow với PKCE (Proof Key for Code Exchange) — flow chuẩn và an toàn nhất cho Single Page Applications (SPA) và 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 là thư viện OIDC hiện đại, dựa trên oidc-client-ts, được khuyên dùng cho React applications mới:

# 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 với 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 là adapter chính thức từ Keycloak. Vẫn hoạt động tốt nhưng react-oidc-context được ưu tiên cho projects mới vì chuẩn OIDC hơn:

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

Tạo client trong Keycloak cho 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 Cài đặt 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 sử dụng 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

Sử dụng thư viện jose để verify JWT tokens — approach nhẹ, không phụ thuộc 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

Nếu đã sử dụng Passport.js trong project, có thể dùng 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 là yếu tố quan trọng để đảm bảo trải nghiệm người dùng liền mạch. Có 2 phương pháp chính:

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

Tạo file public/silent-check-sso.html cho 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

Tham khảo cấu hình Keycloak client cho các loại application:

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

7. Tổng kết

PlatformLibraryƯu điểmLưu ý
Reactreact-oidc-contextChuẩn OIDC, hooks API, auto refreshCần config redirect URIs chính xác
Reactkeycloak-jsOfficial adapter, đầy đủ APITightly coupled với Keycloak
Angularangular-auth-oidc-clientAngular-native, Guards, InterceptorsConfig phức tạp hơn React
Node.jsjoseNhẹ, không dependency, chuẩnCần tự implement middleware
Node.jspassport-keycloak-connectTích hợp Passport.js ecosystemCần session management

Trong bài tiếp theo, chúng ta sẽ tìm hiểu cách tích hợp Keycloak với API Gateway (Nginx, Kong, Traefik) và microservices architecture.