Node.js Integration Guide
Ce contenu n’est pas encore disponible dans votre langue.
This guide shows you how to integrate Keycloak authentication into your Node.js applications. We’ll cover Express, Fastify, and vanilla Node.js implementations with production-ready examples.
Prerequisites
Section titled “Prerequisites”- Node.js 18+ installed
- A Keycloak instance (Skycloak account)
- Basic understanding of OAuth 2.0/OpenID Connect
- Your Keycloak realm and client configured
Quick Start
Section titled “Quick Start”Installation
Section titled “Installation”# Using npm - Recommended packages
npm install openid-client express-session jsonwebtoken jwks-rsa axios
# For Passport.js integration
npm install passport passport-openidconnect
# Using yarn
yarn add openid-client express-session jsonwebtoken jwks-rsa axios
# For TypeScript projects
npm install --save-dev @types/express @types/express-session @types/jsonwebtoken
Basic Express Setup with openid-client
Section titled “Basic Express Setup with openid-client”const express = require('express');
const session = require('express-session');
const { Issuer, generators } = require('openid-client');
const app = express();
// Session configuration
app.use(session({
secret: process.env.SESSION_SECRET || 'your-secret-key',
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
let keycloakClient;
// Initialize OpenID Client
async function initializeOIDC() {
const keycloakIssuer = await Issuer.discover(
`${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}`
);
keycloakClient = new keycloakIssuer.Client({
client_id: process.env.KEYCLOAK_CLIENT_ID,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET,
redirect_uris: [`${process.env.APP_URL}/auth/callback`],
response_types: ['code'],
});
}
// Auth middleware
const requireAuth = (req, res, next) => {
if (!req.session.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
next();
};
// Login route
app.get('/auth/login', (req, res) => {
const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier);
const state = generators.state();
req.session.codeVerifier = codeVerifier;
req.session.state = state;
const authUrl = keycloakClient.authorizationUrl({
scope: 'openid email profile',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state: state,
});
res.redirect(authUrl);
});
// Callback route
app.get('/auth/callback', async (req, res) => {
try {
const params = keycloakClient.callbackParams(req);
const tokenSet = await keycloakClient.callback(
`${process.env.APP_URL}/auth/callback`,
params,
{
code_verifier: req.session.codeVerifier,
state: req.session.state,
}
);
const userInfo = await keycloakClient.userinfo(tokenSet.access_token);
req.session.user = userInfo;
req.session.tokens = {
access_token: tokenSet.access_token,
refresh_token: tokenSet.refresh_token,
id_token: tokenSet.id_token,
};
delete req.session.codeVerifier;
delete req.session.state;
res.redirect('/dashboard');
} catch (error) {
console.error('Auth callback error:', error);
res.status(500).json({ error: 'Authentication failed' });
}
});
// Logout route
app.get('/auth/logout', async (req, res) => {
const idToken = req.session.tokens?.id_token;
req.session.destroy();
if (idToken) {
const logoutUrl = keycloakClient.endSessionUrl({ id_token_hint: idToken });
res.redirect(logoutUrl);
} else {
res.redirect('/');
}
});
// Protected route
app.get('/api/protected', requireAuth, (req, res) => {
res.json({
message: 'This is protected',
user: req.session.user
});
});
// Public route
app.get('/api/public', (req, res) => {
res.json({ message: 'This is public' });
});
// Start server
initializeOIDC().then(() => {
app.listen(3000, () => {
console.log('Server running on port 3000');
});
});
Passport.js Integration
Section titled “Passport.js Integration”For applications already using Passport.js:
const express = require('express');
const session = require('express-session');
const passport = require('passport');
const OpenIDConnectStrategy = require('passport-openidconnect');
const app = express();
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
}));
app.use(passport.initialize());
app.use(passport.session());
// Configure Passport with OpenID Connect
passport.use('keycloak', new OpenIDConnectStrategy({
issuer: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}`,
authorizationURL: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/auth`,
tokenURL: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/token`,
userInfoURL: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/userinfo`,
clientID: process.env.KEYCLOAK_CLIENT_ID,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
callbackURL: `${process.env.APP_URL}/auth/callback`,
scope: 'openid email profile',
}, (issuer, profile, context, idToken, accessToken, refreshToken, done) => {
return done(null, {
id: profile.id,
email: profile.emails?.[0]?.value,
name: profile.displayName,
accessToken,
refreshToken,
idToken,
});
}));
passport.serializeUser((user, done) => done(null, user));
passport.deserializeUser((user, done) => done(null, user));
// Routes
app.get('/auth/login', passport.authenticate('keycloak'));
app.get('/auth/callback',
passport.authenticate('keycloak', { failureRedirect: '/login' }),
(req, res) => res.redirect('/dashboard')
);
app.get('/api/protected',
(req, res, next) => req.isAuthenticated() ? next() : res.status(401).json({ error: 'Unauthorized' }),
(req, res) => res.json({ user: req.user })
);
JWT Token Validation (API Protection)
Section titled “JWT Token Validation (API Protection)”For more control over token validation:
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
const { promisify } = require('util');
// JWKS client setup
const client = jwksClient({
jwksUri: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/certs`,
cache: true,
cacheMaxEntries: 5,
cacheMaxAge: 600000, // 10 minutes
rateLimit: true,
jwksRequestsPerMinute: 10
});
// Get signing key
const getKey = (header, callback) => {
client.getSigningKey(header.kid, (err, key) => {
if (err) {
return callback(err);
}
const signingKey = key.publicKey || key.rsaPublicKey;
callback(null, signingKey);
});
};
// Promisify jwt.verify
const verifyToken = promisify(jwt.verify);
// Token validation middleware
const validateToken = async (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({ error: 'No authorization header' });
}
const token = authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
// Verify token
const decoded = await verifyToken(token, getKey, {
algorithms: ['RS256'],
issuer: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}`,
audience: process.env.KEYCLOAK_CLIENT_ID
});
// Additional validation
if (!decoded.email_verified) {
return res.status(403).json({ error: 'Email not verified' });
}
// Attach user info to request
req.user = {
id: decoded.sub,
email: decoded.email,
name: decoded.name,
roles: decoded.realm_access?.roles || [],
scopes: decoded.scope?.split(' ') || []
};
next();
} catch (error) {
console.error('Token validation error:', error.message);
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
if (error.name === 'JsonWebTokenError') {
return res.status(401).json({ error: 'Invalid token' });
}
res.status(500).json({ error: 'Token validation failed' });
}
};
// Usage
app.get('/api/manual-protected', validateToken, (req, res) => {
res.json({
message: 'Manually validated',
user: req.user
});
});
Fastify Integration
Section titled “Fastify Integration”Basic Fastify Setup
Section titled “Basic Fastify Setup”const fastify = require('fastify')();
const fastifyJWT = require('@fastify/jwt');
const fastifyAuth = require('@fastify/auth');
const axios = require('axios');
// JWT plugin configuration
fastify.register(fastifyJWT, {
secret: {
public: async (request, token) => {
// Fetch public key from Keycloak
const response = await axios.get(
`${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/certs`
);
const key = response.data.keys.find(k => k.kid === token.kid);
if (!key) throw new Error('Key not found');
return key;
}
},
verify: {
iss: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}`,
aud: process.env.KEYCLOAK_CLIENT_ID
}
});
fastify.register(fastifyAuth);
// Authentication decorator
fastify.decorate('authenticate', async (request, reply) => {
try {
await request.jwtVerify();
} catch (err) {
reply.send(err);
}
});
// Role verification decorator
fastify.decorate('verifyRole', (role) => {
return async (request, reply) => {
const roles = request.user.realm_access?.roles || [];
if (!roles.includes(role)) {
reply.status(403).send({ error: 'Insufficient permissions' });
}
};
});
// Routes
fastify.get('/api/public', async (request, reply) => {
return { message: 'Public route' };
});
fastify.get('/api/protected', {
preHandler: fastify.auth([fastify.authenticate])
}, async (request, reply) => {
return { user: request.user };
});
fastify.get('/api/admin', {
preHandler: fastify.auth([
fastify.authenticate,
fastify.verifyRole('admin')
])
}, async (request, reply) => {
return { message: 'Admin route' };
});
// Start server
const start = async () => {
try {
await fastify.listen({ port: 3000, host: '0.0.0.0' });
console.log('Server running on port 3000');
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
Advanced Fastify with Plugins
Section titled “Advanced Fastify with Plugins”// keycloak-plugin.js
const fp = require('fastify-plugin');
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
async function keycloakPlugin(fastify, options) {
const {
realm,
authServerUrl,
clientId,
bearerOnly = true
} = options;
// JWKS client
const client = jwksClient({
jwksUri: `${authServerUrl}/realms/${realm}/protocol/openid-connect/certs`,
cache: true,
cacheMaxEntries: 5,
cacheMaxAge: 600000
});
// Get key function
const getKey = (header, callback) => {
client.getSigningKey(header.kid, (err, key) => {
if (err) return callback(err);
callback(null, key.publicKey || key.rsaPublicKey);
});
};
// Verify token function
const verifyToken = (token) => {
return new Promise((resolve, reject) => {
jwt.verify(token, getKey, {
algorithms: ['RS256'],
issuer: `${authServerUrl}/realms/${realm}`,
audience: clientId
}, (err, decoded) => {
if (err) reject(err);
else resolve(decoded);
});
});
};
// Decorate fastify with auth method
fastify.decorate('keycloakAuth', async function (request, reply) {
const authHeader = request.headers.authorization;
if (!authHeader) {
throw fastify.httpErrors.unauthorized('Missing authorization header');
}
const token = authHeader.replace('Bearer ', '');
try {
const decoded = await verifyToken(token);
request.user = decoded;
} catch (error) {
throw fastify.httpErrors.unauthorized('Invalid token');
}
});
// Add role checking
fastify.decorate('requireRole', (role) => {
return async function (request, reply) {
if (!request.user) {
throw fastify.httpErrors.unauthorized('Not authenticated');
}
const roles = request.user.realm_access?.roles || [];
if (!roles.includes(role)) {
throw fastify.httpErrors.forbidden('Insufficient permissions');
}
};
});
// Add scope checking
fastify.decorate('requireScope', (scope) => {
return async function (request, reply) {
if (!request.user) {
throw fastify.httpErrors.unauthorized('Not authenticated');
}
const scopes = request.user.scope?.split(' ') || [];
if (!scopes.includes(scope)) {
throw fastify.httpErrors.forbidden('Missing required scope');
}
};
});
}
module.exports = fp(keycloakPlugin, {
name: 'fastify-keycloak'
});
// Usage in main app
const fastify = require('fastify')();
fastify.register(require('./keycloak-plugin'), {
realm: process.env.KEYCLOAK_REALM,
authServerUrl: process.env.KEYCLOAK_URL,
clientId: process.env.KEYCLOAK_CLIENT_ID
});
// Protected routes
fastify.get('/api/user', {
preHandler: [fastify.keycloakAuth]
}, async (request, reply) => {
return { user: request.user };
});
fastify.get('/api/admin', {
preHandler: [fastify.keycloakAuth, fastify.requireRole('admin')]
}, async (request, reply) => {
return { message: 'Admin access granted' };
});
Backend for Frontend (BFF) Pattern
Section titled “Backend for Frontend (BFF) Pattern”OAuth Flow Implementation
Section titled “OAuth Flow Implementation”const express = require('express');
const axios = require('axios');
const crypto = require('crypto');
const session = require('express-session');
const app = express();
// Session for storing auth state
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
// Generate PKCE challenge
function generatePKCE() {
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
return { verifier, challenge };
}
// OAuth configuration
const oauth = {
authorizationURL: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/auth`,
tokenURL: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/token`,
userInfoURL: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/userinfo`,
clientId: process.env.KEYCLOAK_CLIENT_ID,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
redirectURI: `${process.env.APP_URL}/auth/callback`
};
// Login route
app.get('/auth/login', (req, res) => {
const state = crypto.randomBytes(16).toString('base64url');
const { verifier, challenge } = generatePKCE();
// Store in session
req.session.oauth = { state, verifier };
// Build authorization URL
const params = new URLSearchParams({
client_id: oauth.clientId,
redirect_uri: oauth.redirectURI,
response_type: 'code',
scope: 'openid email profile',
state: state,
code_challenge: challenge,
code_challenge_method: 'S256'
});
res.redirect(`${oauth.authorizationURL}?${params}`);
});
// Callback route
app.get('/auth/callback', async (req, res) => {
const { code, state } = req.query;
// Verify state
if (!req.session.oauth || state !== req.session.oauth.state) {
return res.status(400).json({ error: 'Invalid state' });
}
try {
// Exchange code for tokens
const tokenResponse = await axios.post(oauth.tokenURL,
new URLSearchParams({
grant_type: 'authorization_code',
client_id: oauth.clientId,
client_secret: oauth.clientSecret,
code: code,
redirect_uri: oauth.redirectURI,
code_verifier: req.session.oauth.verifier
}), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
const { access_token, refresh_token, id_token } = tokenResponse.data;
// Get user info
const userResponse = await axios.get(oauth.userInfoURL, {
headers: {
'Authorization': `Bearer ${access_token}`
}
});
// Store in session
req.session.user = userResponse.data;
req.session.tokens = {
access_token,
refresh_token,
id_token
};
// Clean up OAuth session data
delete req.session.oauth;
// Redirect to app
res.redirect('/dashboard');
} catch (error) {
console.error('OAuth callback error:', error);
res.status(500).json({ error: 'Authentication failed' });
}
});
// Refresh token
app.post('/auth/refresh', async (req, res) => {
if (!req.session.tokens?.refresh_token) {
return res.status(401).json({ error: 'No refresh token' });
}
try {
const response = await axios.post(oauth.tokenURL,
new URLSearchParams({
grant_type: 'refresh_token',
client_id: oauth.clientId,
client_secret: oauth.clientSecret,
refresh_token: req.session.tokens.refresh_token
}), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
// Update tokens
req.session.tokens = {
...req.session.tokens,
access_token: response.data.access_token,
refresh_token: response.data.refresh_token || req.session.tokens.refresh_token
};
res.json({ success: true });
} catch (error) {
console.error('Token refresh error:', error);
res.status(401).json({ error: 'Refresh failed' });
}
});
// Logout
app.post('/auth/logout', async (req, res) => {
const refreshToken = req.session.tokens?.refresh_token;
// Destroy session
req.session.destroy();
// Revoke token at Keycloak (optional)
if (refreshToken) {
try {
await axios.post(
`${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/logout`,
new URLSearchParams({
client_id: oauth.clientId,
client_secret: oauth.clientSecret,
refresh_token: refreshToken
})
);
} catch (error) {
console.error('Token revocation error:', error);
}
}
res.json({ success: true });
});
// Protected API proxy
app.use('/api', (req, res, next) => {
if (!req.session.tokens?.access_token) {
return res.status(401).json({ error: 'Not authenticated' });
}
// Add token to proxied requests
req.headers.authorization = `Bearer ${req.session.tokens.access_token}`;
next();
});
// Proxy to backend API
const { createProxyMiddleware } = require('http-proxy-middleware');
app.use('/api', createProxyMiddleware({
target: process.env.BACKEND_API_URL,
changeOrigin: true,
onError: (err, req, res) => {
if (err.code === 'ECONNRESET' || err.message.includes('401')) {
// Try to refresh token
// Implement refresh logic here
}
res.status(500).json({ error: 'Proxy error' });
}
}));
Service-to-Service Authentication
Section titled “Service-to-Service Authentication”Client Credentials Flow
Section titled “Client Credentials Flow”const axios = require('axios');
class KeycloakServiceClient {
constructor(config) {
this.config = config;
this.token = null;
this.tokenExpiry = null;
}
async getToken() {
// Check if token is still valid
if (this.token && this.tokenExpiry > Date.now()) {
return this.token;
}
try {
const response = await axios.post(
`${this.config.authServerUrl}/realms/${this.config.realm}/protocol/openid-connect/token`,
new URLSearchParams({
grant_type: 'client_credentials',
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
scope: this.config.scope || ''
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
this.token = response.data.access_token;
// Set expiry with 60 second buffer
this.tokenExpiry = Date.now() + (response.data.expires_in - 60) * 1000;
return this.token;
} catch (error) {
console.error('Failed to get service token:', error);
throw new Error('Service authentication failed');
}
}
async makeAuthenticatedRequest(url, options = {}) {
const token = await this.getToken();
return axios({
...options,
url,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`
}
});
}
}
// Usage
const serviceClient = new KeycloakServiceClient({
authServerUrl: process.env.KEYCLOAK_URL,
realm: process.env.KEYCLOAK_REALM,
clientId: process.env.SERVICE_CLIENT_ID,
clientSecret: process.env.SERVICE_CLIENT_SECRET
});
// Make authenticated service calls
async function callOtherService() {
try {
const response = await serviceClient.makeAuthenticatedRequest(
'https://other-service.example.com/api/data',
{
method: 'GET'
}
);
return response.data;
} catch (error) {
console.error('Service call failed:', error);
throw error;
}
}
WebSocket Authentication
Section titled “WebSocket Authentication”Socket.io with Keycloak
Section titled “Socket.io with Keycloak”const express = require('express');
const { createServer } = require('http');
const { Server } = require('socket.io');
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
const app = express();
const httpServer = createServer(app);
// JWKS client for token validation
const client = jwksClient({
jwksUri: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/certs`
});
const getKey = (header, callback) => {
client.getSigningKey(header.kid, (err, key) => {
if (err) return callback(err);
callback(null, key.publicKey || key.rsaPublicKey);
});
};
// Socket.io setup with auth
const io = new Server(httpServer, {
cors: {
origin: process.env.CLIENT_URL,
credentials: true
}
});
// Authentication middleware
io.use(async (socket, next) => {
try {
const token = socket.handshake.auth.token;
if (!token) {
return next(new Error('Authentication error'));
}
// Verify token
jwt.verify(token, getKey, {
algorithms: ['RS256'],
issuer: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}`,
audience: process.env.KEYCLOAK_CLIENT_ID
}, (err, decoded) => {
if (err) {
return next(new Error('Authentication error'));
}
// Attach user info to socket
socket.userId = decoded.sub;
socket.user = {
id: decoded.sub,
email: decoded.email,
name: decoded.name,
roles: decoded.realm_access?.roles || []
};
next();
});
} catch (err) {
next(new Error('Authentication error'));
}
});
// Connection handling
io.on('connection', (socket) => {
console.log(`User ${socket.user.email} connected`);
// Join user to their personal room
socket.join(`user:${socket.userId}`);
// Join role-based rooms
socket.user.roles.forEach(role => {
socket.join(`role:${role}`);
});
// Handle events
socket.on('message', (data) => {
// Validate permissions before processing
if (socket.user.roles.includes('chat_user')) {
io.to('role:chat_user').emit('message', {
user: socket.user.name,
message: data.message,
timestamp: new Date()
});
}
});
socket.on('disconnect', () => {
console.log(`User ${socket.user.email} disconnected`);
});
});
// Client-side connection
/*
const socket = io('http://localhost:3000', {
auth: {
token: localStorage.getItem('access_token')
}
});
socket.on('connect', () => {
console.log('Connected to server');
});
socket.on('connect_error', (error) => {
if (error.message === 'Authentication error') {
// Refresh token and retry
}
});
*/
httpServer.listen(3000);
Error Handling and Monitoring
Section titled “Error Handling and Monitoring”Comprehensive Error Handling
Section titled “Comprehensive Error Handling”const winston = require('winston');
const { ElasticsearchTransport } = require('winston-elasticsearch');
// Logger setup
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' }),
new ElasticsearchTransport({
level: 'info',
clientOpts: { node: process.env.ELASTICSEARCH_URL },
index: 'keycloak-auth'
})
]
});
// Auth error handler middleware
const authErrorHandler = (err, req, res, next) => {
// Log error details
logger.error('Authentication error', {
error: err.message,
stack: err.stack,
path: req.path,
method: req.method,
ip: req.ip,
user: req.user?.id
});
// Handle specific auth errors
if (err.name === 'UnauthorizedError') {
return res.status(401).json({
error: 'Unauthorized',
message: 'Invalid or expired token'
});
}
if (err.name === 'InsufficientScopeError') {
return res.status(403).json({
error: 'Forbidden',
message: 'Insufficient permissions'
});
}
// Default error response
res.status(500).json({
error: 'Internal Server Error',
message: process.env.NODE_ENV === 'development' ? err.message : 'An error occurred'
});
};
// Metrics collection
const promClient = require('prom-client');
const authCounter = new promClient.Counter({
name: 'auth_requests_total',
help: 'Total number of authentication requests',
labelNames: ['method', 'status']
});
const authDuration = new promClient.Histogram({
name: 'auth_duration_seconds',
help: 'Authentication request duration',
labelNames: ['method']
});
// Metrics middleware
const metricsMiddleware = (req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = (Date.now() - start) / 1000;
if (req.path.startsWith('/auth')) {
authCounter.inc({
method: req.method,
status: res.statusCode
});
authDuration.observe({
method: req.method
}, duration);
}
});
next();
};
app.use(metricsMiddleware);
app.use(authErrorHandler);
// Metrics endpoint
app.get('/metrics', (req, res) => {
res.set('Content-Type', promClient.register.contentType);
res.end(promClient.register.metrics());
});
Testing
Section titled “Testing”Unit Testing Authentication
Section titled “Unit Testing Authentication”const request = require('supertest');
const jwt = require('jsonwebtoken');
const app = require('./app');
// Mock Keycloak token
function createMockToken(payload = {}) {
return jwt.sign({
sub: 'test-user-id',
email: 'test@example.com',
name: 'Test User',
realm_access: { roles: ['user'] },
...payload
}, 'test-secret', { algorithm: 'HS256' });
}
describe('Authentication Tests', () => {
describe('Protected Routes', () => {
it('should reject requests without token', async () => {
const response = await request(app)
.get('/api/protected')
.expect(401);
expect(response.body.error).toBe('No authorization header');
});
it('should accept valid token', async () => {
const token = createMockToken();
const response = await request(app)
.get('/api/protected')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(response.body.user.email).toBe('test@example.com');
});
it('should reject expired token', async () => {
const token = createMockToken({ exp: Math.floor(Date.now() / 1000) - 3600 });
await request(app)
.get('/api/protected')
.set('Authorization', `Bearer ${token}`)
.expect(401);
});
});
describe('Role-Based Access', () => {
it('should allow admin access to admin routes', async () => {
const token = createMockToken({
realm_access: { roles: ['user', 'admin'] }
});
await request(app)
.get('/api/admin')
.set('Authorization', `Bearer ${token}`)
.expect(200);
});
it('should deny non-admin access to admin routes', async () => {
const token = createMockToken({
realm_access: { roles: ['user'] }
});
const response = await request(app)
.get('/api/admin')
.set('Authorization', `Bearer ${token}`)
.expect(403);
expect(response.body.error).toBe('Insufficient permissions');
});
});
});
Integration Testing
Section titled “Integration Testing”const axios = require('axios');
const { startServer, stopServer } = require('./server');
describe('Keycloak Integration Tests', () => {
let server;
let keycloakToken;
beforeAll(async () => {
server = await startServer();
// Get real token from Keycloak
const response = await axios.post(
`${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/token`,
new URLSearchParams({
grant_type: 'password',
client_id: process.env.KEYCLOAK_CLIENT_ID,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET,
username: 'test-user',
password: 'test-password'
})
);
keycloakToken = response.data.access_token;
});
afterAll(async () => {
await stopServer(server);
});
it('should authenticate with real Keycloak token', async () => {
const response = await axios.get('http://localhost:3000/api/user', {
headers: {
'Authorization': `Bearer ${keycloakToken}`
}
});
expect(response.status).toBe(200);
expect(response.data.user.email).toBeDefined();
});
});
Best Practices
Section titled “Best Practices”1. Security
Section titled “1. Security”- Always validate tokens on every request
- Use HTTPS in production
- Implement proper CORS configuration
- Store sensitive config in environment variables
- Rotate client secrets regularly
2. Performance
Section titled “2. Performance”- Cache JWKS keys
- Implement token caching where appropriate
- Use connection pooling for Redis/database
- Monitor token validation performance
3. Error Handling
Section titled “3. Error Handling”- Provide clear error messages
- Log authentication failures
- Implement retry logic for token refresh
- Handle edge cases gracefully
4. Monitoring
Section titled “4. Monitoring”- Track authentication metrics
- Monitor token expiration patterns
- Alert on unusual authentication patterns
- Log security events
Troubleshooting
Section titled “Troubleshooting”Common Issues
Section titled “Common Issues”Token Validation Fails
// Debug token validation
const decoded = jwt.decode(token, { complete: true });
console.log('Token header:', decoded.header);
console.log('Token payload:', decoded.payload);
// Verify JWKS endpoint
const jwksResponse = await axios.get(`${keycloakUrl}/realms/${realm}/protocol/openid-connect/certs`);
console.log('Available keys:', jwksResponse.data.keys.map(k => k.kid));
CORS Issues
// Proper CORS configuration
app.use(cors({
origin: function (origin, callback) {
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [];
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
Session Issues
// Debug session
app.use((req, res, next) => {
console.log('Session ID:', req.sessionID);
console.log('Session data:', req.session);
next();
});