The component development pipeline for our micro-frontend architecture was grinding to a halt. We had a suite of isolated UI components developed in Storybook, and a collection of Node.js microservices providing data, each secured with a standard JWT-based authentication scheme. The disconnect was stark and painful. Any component that required data fetching—a user profile card, a dashboard widget, a product list—was fundamentally broken within the Storybook environment. The API calls would be fired off, only to be met with a cascade of 401 Unauthorized
responses.
Our initial workaround was primitive: hard-coded JSON mocks passed as props. This approach was a ticking time bomb of technical debt. The static mocks quickly diverged from the actual API contracts, leading to components that worked perfectly in Storybook but failed spectacularly upon integration. We were shipping bugs rooted in this development environment mismatch. The cost of context-switching for developers, who had to constantly validate their work against a live environment, was becoming unacceptable.
A more robust solution was imperative. The core requirements were clear:
- The solution must replicate the production authentication flow (JWT Bearer tokens) within Storybook.
- Components must not be modified to accommodate the mock environment; the data-fetching logic (
fetch
,axios
, etc.) should work as-is. - The mocking layer must be capable of simulating different user roles and permissions defined in the JWT payload.
- The system must be isolated from live backend services to ensure a stable, deterministic, and fast development experience.
Disabling authentication in development environments was a non-starter. It introduces security vulnerabilities and, more importantly, it fails to model the real-world behavior of the application, particularly around role-based access control (RBAC). The path forward pointed towards intercepting network requests initiated by the components and responding with realistic, stateful data that respected a simulated authentication context. This led us to select Mock Service Worker (MSW) as the cornerstone of our new development infrastructure. MSW’s ability to intercept requests at the network level using a Service Worker API meant we could build our solution without intruding on the component’s implementation details.
The Architectural Blueprint: An Integrated Mocking Ecosystem
The final architecture consists of three tightly integrated parts, all orchestrated within the Storybook ecosystem:
- A Standalone Node.js Mock Authentication Service: A lightweight Express.js server whose sole purpose is to issue JWTs for predefined user personas (e.g., ‘admin’, ‘standard-user’, ‘guest’). This service mimics our production identity provider but runs locally, ensuring isolation.
- Mock Service Worker (MSW) Handlers: A set of request handlers that intercept all API calls originating from our components. These handlers are the “brains” of the operation; they validate the incoming mock JWT and return different data payloads based on the user’s claims.
- Storybook Integration Layer: A custom Storybook toolbar and a global decorator that act as the user interface for this system. Developers can select a user persona from the toolbar, which triggers a request to the mock auth service, stores the resulting JWT in Storybook’s global state, and re-renders the component within an authenticated context.
Here is the flow of control in this system:
sequenceDiagram participant Dev as Developer participant SB_Toolbar as Storybook Toolbar participant NodeMockAuth as Node.js Mock Auth Service participant SB_Global as Storybook Globals participant MSW as Mock Service Worker participant Component as React Component Dev->>SB_Toolbar: Selects 'Admin' persona SB_Toolbar->>NodeMockAuth: POST /login { username: 'admin' } NodeMockAuth-->>SB_Toolbar: Returns JWT for Admin SB_Toolbar->>SB_Global: Stores Admin JWT SB_Global-->>Component: Triggers re-render Component->>MSW: fetch('/api/users/1') with 'Authorization' header Note right of MSW: Intercepts network request MSW->>MSW: Decodes JWT from header Note right of MSW: Finds 'role: admin' in payload MSW-->>Component: Returns detailed user data (Admin view)
This design decouples the authentication simulation from the data mocking, allowing us to maintain a clean separation of concerns and build a system that closely mirrors our production environment’s logic.
Phase 1: The Node.js Mock Authentication Service
The foundation is a minimal yet robust Node.js server using Express and jsonwebtoken
. Its responsibility is limited to issuing tokens for a static set of users. In a real-world project, this server must be kept simple to avoid it becoming another complex service to maintain.
File structure:
/mock-auth-server
- package.json
- server.js
package.json
{
"name": "mock-auth-server",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "node server.js"
},
"dependencies": {
"body-parser": "^1.20.2",
"cors": "^2.8.5",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2"
}
}
server.js
This server exposes a single /login
endpoint. It doesn’t need a real password check; the goal is simply to issue a token corresponding to a known user persona.
// mock-auth-server/server.js
const express = require('express');
const bodyParser = require('body-parser');
const jwt = a require('jsonwebtoken');
const cors = require('cors');
const app = express();
const PORT = 3010; // A dedicated port for the mock service
// A common mistake is to commit secrets. In a real project, this should come from environment variables.
// For this local-only mock server, defining it here is acceptable for simplicity.
const JWT_SECRET = 'a-super-secret-key-for-dev-only';
// Our stable of predefined user personas for development.
const users = {
admin: {
id: 'user-1',
name: 'Admin User',
roles: ['admin', 'editor', 'viewer'],
permissions: ['read:users', 'write:users', 'delete:users']
},
editor: {
id: 'user-2',
name: 'Editor User',
roles: ['editor', 'viewer'],
permissions: ['read:users', 'write:users']
},
viewer: {
id: 'user-3',
name: 'Viewer User',
roles: ['viewer'],
permissions: ['read:users']
}
};
app.use(cors()); // Allow requests from the Storybook origin
app.use(bodyParser.json());
// Logger middleware for visibility into mock auth requests.
app.use((req, res, next) => {
console.log(`[MockAuth] ${new Date().toISOString()} - ${req.method} ${req.originalUrl}`);
next();
});
app.post('/login', (req, res) => {
const { username } = req.body;
if (!username || !users[username]) {
console.error(`[MockAuth] Login failed: User '${username}' not found.`);
return res.status(404).json({ message: 'User persona not found' });
}
const userProfile = users[username];
// The payload contains all the necessary data for authorization decisions in the mock layer.
const payload = {
sub: userProfile.id,
name: userProfile.name,
roles: userProfile.roles,
permissions: userProfile.permissions,
iat: Math.floor(Date.now() / 1000)
};
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' });
console.log(`[MockAuth] Issued token for user: ${username}`);
res.status(200).json({ token });
});
// A simple health check endpoint.
app.get('/health', (req, res) => {
res.status(200).json({ status: 'OK' });
});
// Basic error handling
app.use((err, req, res, next) => {
console.error('[MockAuth] An unexpected error occurred:', err.stack);
res.status(500).json({ message: 'Internal Server Error' });
});
app.listen(PORT, () => {
console.log(`[MockAuth] Mock Authentication Server running on http://localhost:${PORT}`);
});
To run this, we add a script to our main project’s package.json
to start it alongside Storybook, perhaps using a tool like concurrently
.
Phase 2: Integrating MSW and the Storybook Control Layer
With the token issuer ready, the next step is to configure Storybook to use it. This involves setting up MSW, creating a custom toolbar for persona selection, and a decorator to provide the authentication context to our stories.
1. MSW Setup
First, install MSW and its Storybook addon:npm install msw msw-storybook-addon --save-dev
Then, initialize MSW in the project root:npx msw init public/ --save
This creates a mockServiceWorker.js
file in the public
directory, which is crucial for enabling network interception in the browser.
In .storybook/preview.js
, we initialize the addon:
// .storybook/preview.js
import { initialize, mswDecorator } from 'msw-storybook-addon';
// Initialize MSW
initialize();
export const decorators = [mswDecorator];
// ... rest of preview.js config
2. Storybook Globals and Toolbar
We define globals in .storybook/preview.js
to hold the authentication state.
// .storybook/preview.js
// ... imports
initialize();
export const decorators = [mswDecorator];
export const globalTypes = {
// This defines the custom toolbar dropdown
userPersona: {
name: 'User Persona',
description: 'Simulate different user roles',
defaultValue: 'guest',
toolbar: {
icon: 'user',
items: [
{ value: 'guest', title: 'Guest (No Auth)' },
{ value: 'viewer', title: 'Viewer' },
{ value: 'editor', title: 'Editor' },
{ value: 'admin', title: 'Admin' },
],
showName: true,
},
},
// This global will store the actual token
authToken: {
name: 'Auth Token',
description: 'Internal state for JWT',
defaultValue: null,
toolbar: {
// Hide this from the toolbar as it's managed internally
hidden: true,
},
},
};
3. The Authentication Decorator
This is the glue. We create a decorator that watches for changes in the userPersona
global. When it changes, it fetches a new JWT from our mock auth server and stores it in the authToken
global. This authToken
is then accessible to our MSW handlers.
// .storybook/AuthDecorator.js
import { useEffect, useState } from 'react';
// This custom hook manages the token fetching logic.
const useAuthToken = (persona, updateGlobals) => {
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const fetchToken = async () => {
if (persona === 'guest') {
updateGlobals({ authToken: null });
return;
}
setIsLoading(true);
try {
const response = await fetch('http://localhost:3010/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: persona }),
});
if (!response.ok) {
throw new Error(`Failed to fetch token for ${persona}: ${response.statusText}`);
}
const { token } = await response.json();
// Update the global state so MSW can access it
updateGlobals({ authToken: token });
} catch (error) {
console.error("AuthDecorator Error:", error);
// On failure, revert to unauthenticated state
updateGlobals({ authToken: null });
} finally {
setIsLoading(false);
}
};
fetchToken();
}, [persona, updateGlobals]);
return { isLoading };
};
export const AuthDecorator = (Story, context) => {
const { userPersona } = context.globals;
const { updateGlobals } = context;
const { isLoading } = useAuthToken(userPersona, updateGlobals);
if (isLoading) {
return <div>Authenticating as {userPersona}...</div>;
}
return <Story {...context} />;
};
Now, we add this decorator to .storybook/preview.js
:
// .storybook/preview.js
import { initialize, mswDecorator } from 'msw-storybook-addon';
import { AuthDecorator } from './AuthDecorator'; // Import our new decorator
initialize();
export const decorators = [
mswDecorator,
AuthDecorator // Add our decorator
];
// ... globalTypes definition ...
Phase 3: Crafting Role-Aware MSW Handlers
The final piece is to make our MSW handlers aware of the authentication context. The handlers will read the Authorization
header from the intercepted request, validate the token (using the same shared secret as the mock auth server), and return a response tailored to the user’s permissions.
We’ll use jose
for robust JWT parsing, as it’s a modern, dependency-free library. npm install jose --save-dev
.
// src/mocks/handlers.js
import { rest } from 'msw';
import * as jose from 'jose';
// This MUST match the secret in the mock-auth-server.
const JWT_SECRET = 'a-super-secret-key-for-dev-only';
const secretKey = new TextEncoder().encode(JWT_SECRET);
// A helper function to verify the token from the request headers.
// A common pitfall is to forget proper error handling here.
// The function must gracefully handle missing tokens, malformed tokens, and invalid signatures.
async function verifyAuth(req) {
const authHeader = req.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return { error: 'Missing or malformed Authorization header', status: 401 };
}
const token = authHeader.split(' ')[1];
try {
const { payload } = await jose.jwtVerify(token, secretKey);
return { payload };
} catch (err) {
console.error('[MSW Auth] Token verification failed:', err.message);
// Differentiate between expired and invalid tokens if needed.
return { error: 'Invalid or expired token', status: 403 };
}
}
// Mock data
const usersData = {
'user-1': { id: 'user-1', name: 'Admin User', email: '[email protected]', lastLogin: new Date().toISOString() },
'user-2': { id: 'user-2', name: 'Editor User', email: '[email protected]' },
'user-3': { id: 'user-3', name: 'Viewer User', email: '[email protected]' },
};
export const handlers = [
// A public endpoint that doesn't require authentication.
rest.get('/api/status', (req, res, ctx) => {
return res(ctx.status(200), ctx.json({ status: 'API is running' }));
}),
// A protected endpoint that returns user data.
rest.get('/api/user/profile', async (req, res, ctx) => {
const auth = await verifyAuth(req);
if (auth.error) {
return res(ctx.status(auth.status), ctx.json({ message: auth.error }));
}
// The user's ID is in the 'sub' claim of the JWT payload.
const userId = auth.payload.sub;
const userProfile = usersData[userId];
if (!userProfile) {
return res(ctx.status(404), ctx.json({ message: 'User profile not found' }));
}
return res(ctx.status(200), ctx.json(userProfile));
}),
// An admin-only endpoint.
rest.delete('/api/users/:userId', async (req, res, ctx) => {
const auth = await verifyAuth(req);
if (auth.error) {
return res(ctx.status(auth.status), ctx.json({ message: auth.error }));
}
// Check for specific permissions from the JWT payload.
if (!auth.payload.permissions?.includes('delete:users')) {
return res(ctx.status(403), ctx.json({ message: 'Forbidden: You do not have permission to delete users.' }));
}
const { userId } = req.params;
console.log(`[MSW] Admin user '${auth.payload.name}' deleting user '${userId}'.`);
// In a real scenario, you might manipulate a mock database state here.
return res(ctx.status(204));
}),
];
We then import these handlers into .storybook/preview.js
:
// .storybook/preview.js
// ... other imports
import { handlers } from '../src/mocks/handlers';
// ... other config
export const parameters = {
msw: {
handlers: handlers,
},
};
The Result: A Fully Functional and Secure Component Story
Now we can write a story for a UserProfile
component that showcases the entire system. The component itself is simple; it fetches data from /api/user/profile
.
// src/components/UserProfile.jsx
import React, { useState, useEffect } from 'react';
export const UserProfile = ({ authToken }) => {
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchProfile = async () => {
setLoading(true);
setError(null);
setUser(null);
if (!authToken) {
setError('No authentication token provided.');
setLoading(false);
return;
}
try {
const response = await fetch('/api/user/profile', {
headers: {
'Authorization': `Bearer ${authToken}`,
},
});
if (!response.ok) {
throw new Error(`API responded with ${response.status}: ${response.statusText}`);
}
const data = await response.json();
setUser(data);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
};
fetchProfile();
}, [authToken]);
if (loading) return <div>Loading profile...</div>;
if (error) return <div style={{ color: 'red' }}>Error: {error}</div>;
if (!user) return <div>No user data.</div>;
return (
<div>
<h2>{user.name}</h2>
<p>ID: {user.id}</p>
<p>Email: {user.email}</p>
{user.lastLogin && <p>Last Login (Admin Only): {user.lastLogin}</p>}
</div>
);
};
And the story that uses our global authToken
:
// src/stories/UserProfile.stories.jsx
import React from 'react';
import { UserProfile } from '../components/UserProfile';
export default {
title: 'Components/UserProfile',
component: UserProfile,
};
const Template = (args, { globals }) => {
// We grab the authToken directly from Storybook's global context.
// The AuthDecorator ensures this is always up-to-date.
const { authToken } = globals;
return <UserProfile {...args} authToken={authToken} />;
};
export const Default = Template.bind({});
Default.args = {};
// We don't need separate stories for each user type anymore.
// The developer can simply use the toolbar to switch between personas,
// and the component will react accordingly. This significantly
// reduces story duplication and maintenance.
With this setup, a developer can open Storybook, select the “Admin” persona from the toolbar, and see the UserProfile
component render the full details, including the lastLogin
field. Switching to “Viewer” will re-render the component with the viewer’s data, which lacks the lastLogin
field, perfectly simulating the API’s RBAC logic. Selecting “Guest” immediately shows the “No authentication token provided” error state. This provides an immediate, high-fidelity feedback loop that was previously impossible.
Limitations and Future Directions
This implementation provides a powerful and realistic development environment, but it’s not without its own set of trade-offs and potential future enhancements. The mock server’s logic and data structures can drift from the production services over time. To combat this, a contract testing framework like Pact could be integrated. The mock server would act as a provider in the Pact tests, ensuring its responses conform to the contracts established by the consumer components.
Furthermore, the current JWT handling logic does not account for token expiration and refresh flows. A more advanced iteration could involve setting shorter expiry times on the mock tokens and extending the MSW handlers to simulate the token refresh endpoint (/api/refresh-token
). The AuthDecorator
could be enhanced to manage this refresh cycle, providing an even more accurate simulation of the production authentication lifecycle. This would allow for testing edge cases where a component makes an API call with an expired token and must handle the refresh logic correctly.