Autenticación
Tabla de contenidos
- Visión general
- Integración de AuthKit en la Shell App
- Flujo de autenticación: paso a paso
- Validación de JWT en el API Gateway
- Gestión de sesiones
- Multi-tenancy basado en organizaciones
- Contexto de autenticación para MFEs
- Consideraciones de seguridad
- Referencias
Visión general
La plataforma utiliza WorkOS AuthKit como proveedor de autenticación. AuthKit proporciona una interfaz de autenticación alojada e integrable, respaldada por la infraestructura de identidad de WorkOS. Esto elimina la necesidad de construir y mantener flujos de login, lógica de restablecimiento de contraseñas o integraciones SSO desde cero.
Por qué WorkOS
WorkOS fue seleccionado frente a alternativas (Auth0, Clerk, Firebase Auth) por las siguientes razones:
- SSO empresarial incluido: Soporte nativo para SAML 2.0 y federación OIDC. Cuando un cliente requiere SSO con su proveedor de identidad (Okta, Azure AD, Google Workspace), es un cambio de configuración en el dashboard de WorkOS, no un cambio de código.
- Directory Sync (SCIM): Aprovisionamiento y desaprovisionamiento automático de usuarios desde los proveedores de identidad del cliente. Cuando un empleado es eliminado del directorio Okta de un cliente, su acceso se revoca automáticamente.
- Tier gratuito generoso: 1 millón de usuarios activos mensuales sin coste. Esto elimina el coste de autenticación como preocupación de escalado durante el crecimiento inicial.
- Validación de JWT compatible con edge: WorkOS emite JWTs estándar con un endpoint JWKS público. Los tokens se pueden validar completamente en el edge usando la Web Crypto API, sin necesidad de ida y vuelta a los servidores de WorkOS tras la obtención inicial de claves.
- Soporte multi-organización: Soporte de primera clase para usuarios que pertenecen a múltiples organizaciones, lo cual encaja directamente con nuestra arquitectura multi-tenant.
Modelo de autenticación
El flujo de autenticación sigue el patrón AuthKit hosted OAuth:
AuthKit Hosted Login --> Authorization Code --> BFF Worker Token Exchange --> JWT --> HttpOnly Cookie Session
El navegador nunca maneja tokens en crudo. Todo el intercambio y almacenamiento de tokens se gestiona del lado del servidor por los BFF (Backend for Frontend) Workers en Cloudflare. El cliente recibe únicamente una cookie de sesión opaca y cifrada.
Integración de AuthKit en la Shell App
Configuración de AuthKitProvider
La shell application es el punto único de inicialización de autenticación. Un solo AuthKitProvider envuelve todo el árbol de la aplicación, incluyendo todos los micro frontends cargados dinámicamente. Esto asegura que cada MFE hereda el mismo contexto de autenticación sin necesitar su propio provider.
// apps/shell/src/App.tsx
// package.json: "@workos-inc/authkit-react": "^1.3.0"
import { AuthKitProvider } from '@workos-inc/authkit-react';
import { RouterProvider } from 'react-router-dom';
import { router } from './router';
function App() {
return (
<AuthKitProvider
clientId={import.meta.env.RSBUILD_PUBLIC_WORKOS_CLIENT_ID}
apiHostname="auth.example.com" // Custom domain pointing to WorkOS
redirectUri={import.meta.env.RSBUILD_PUBLIC_AUTH_REDIRECT_URI}
onRedirectCallback={(state) => {
// Navigate to the route the user originally requested
window.history.replaceState({}, '', state?.returnTo || '/');
}}
>
<RouterProvider router={router} />
</AuthKitProvider>
);
}
export default App;
Notas de configuración:
| Variable de entorno | Valor de ejemplo | Propósito |
|---|---|---|
RSBUILD_PUBLIC_WORKOS_CLIENT_ID | client_01H... | Client ID del proyecto en WorkOS |
RSBUILD_PUBLIC_AUTH_REDIRECT_URI | https://app.example.com/auth/callback | URL de callback OAuth registrada en el dashboard de WorkOS |
El apiHostname se configura con un dominio personalizado (auth.example.com) que apunta por CNAME a WorkOS. Esto mantiene el flujo de autenticación dentro del dominio example.com, lo cual es importante para compartir cookies y generar confianza en el usuario.
Hook useAuth
La shell app accede al estado de autenticación a través del hook useAuth() proporcionado por el AuthKit React SDK. Este hook expone lo siguiente:
| Propiedad / Método | Tipo | Descripción |
|---|---|---|
user | User | null | El objeto del usuario autenticado, o null si no está autenticado |
isLoading | boolean | true mientras se resuelve el estado inicial de autenticación |
getAccessToken() | () => Promise<string> | Devuelve un access token válido (gestiona la renovación de forma transparente) |
signIn() | () => void | Redirige al login alojado de AuthKit |
signOut() | () => Promise<void> | Finaliza la sesión y redirige al login |
La shell crea un contexto de autenticación ligero que pasa a los MFEs:
// apps/shell/src/contexts/AuthContext.tsx
import { createContext, useContext, useMemo } from 'react';
// package.json: "@workos-inc/authkit-react": "^1.3.0"
import { useAuth } from '@workos-inc/authkit-react';
export interface AuthContextValue {
user: {
id: string;
email: string;
firstName: string | null;
lastName: string | null;
profilePictureUrl: string | null;
organizationId: string | null;
} | null;
isAuthenticated: boolean;
isLoading: boolean;
signIn: () => void;
signOut: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function ShellAuthProvider({ children }: { children: React.ReactNode }) {
const { user, isLoading, signIn, signOut } = useAuth();
const value = useMemo<AuthContextValue>(
() => ({
user: user
? {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
profilePictureUrl: user.profilePictureUrl,
organizationId: user.organizationId,
}
: null,
isAuthenticated: !!user,
isLoading,
signIn,
signOut,
}),
[user, isLoading, signIn, signOut]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useShellAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error('useShellAuth must be used within ShellAuthProvider');
}
return ctx;
}
Rutas protegidas
La shell envuelve las rutas de los MFEs con un guard de autenticación que redirige a los usuarios no autenticados a la página de login de AuthKit. Las rutas que deben ser públicas (páginas de marketing, callback de login) quedan excluidas del guard.
// apps/shell/src/components/ProtectedRoute.tsx
import { Navigate, useLocation } from 'react-router-dom';
import { useShellAuth } from '../contexts/AuthContext';
interface ProtectedRouteProps {
children: React.ReactNode;
requiredPermissions?: string[];
}
export function ProtectedRoute({
children,
requiredPermissions = [],
}: ProtectedRouteProps) {
const { isAuthenticated, isLoading, user, signIn } = useShellAuth();
const location = useLocation();
// Show nothing while auth state is resolving to prevent flash
if (isLoading) {
return <LoadingSkeleton />;
}
// Not authenticated: redirect to AuthKit login
if (!isAuthenticated) {
// Store the current path so we can redirect back after login
sessionStorage.setItem('auth:returnTo', location.pathname + location.search);
signIn();
return null;
}
// Check permissions if required
if (requiredPermissions.length > 0) {
const userPermissions = (user as any)?.permissions ?? [];
const hasAll = requiredPermissions.every((p) => userPermissions.includes(p));
if (!hasAll) {
return <Navigate to="/unauthorized" replace />;
}
}
return <>{children}</>;
}
function LoadingSkeleton() {
return (
<div className="flex items-center justify-center h-screen">
<div className="animate-pulse space-y-4 w-full max-w-md">
<div className="h-4 bg-neutral-200 rounded w-3/4" />
<div className="h-4 bg-neutral-200 rounded w-1/2" />
<div className="h-4 bg-neutral-200 rounded w-5/6" />
</div>
</div>
);
}
Uso en la configuración del router:
// apps/shell/src/router.tsx
import { createBrowserRouter } from 'react-router-dom';
import { ProtectedRoute } from './components/ProtectedRoute';
import { AppLayout } from './layouts/AppLayout';
import { AuthCallback } from './pages/AuthCallback';
import { lazy } from 'react';
const DashboardMFE = lazy(() => import('dashboard/App'));
const SettingsMFE = lazy(() => import('settings/App'));
const BillingMFE = lazy(() => import('billing/App'));
export const router = createBrowserRouter([
// Public route: OAuth callback
{ path: '/auth/callback', element: <AuthCallback /> },
// Protected routes: all MFEs
{
path: '/',
element: (
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
),
children: [
{ index: true, element: <DashboardMFE /> },
{ path: 'settings/*', element: <SettingsMFE /> },
{
path: 'billing/*',
element: (
<ProtectedRoute requiredPermissions={['billing:read']}>
<BillingMFE />
</ProtectedRoute>
),
},
],
},
]);
Flujo de autenticación: paso a paso
El siguiente diagrama muestra el flujo completo de autenticación desde un usuario no autenticado que visita la app hasta una sesión completamente autenticada:
Browser Shell App BFF Worker WorkOS AuthKit
| | | |
| 1. GET app.example.com | |
|--------------------->| | |
| | | |
| 2. No session cookie detected | |
| Shell calls signIn() | |
| | | |
| 3. Redirect to AuthKit hosted login | |
|---------------------------------------------------------------> |
| | | |
| 4. User authenticates (email/pass, SSO, Google, etc.) |
| | | |
| 5. AuthKit redirects back with authorization code |
|<--------------------------------------------------------------- |
| Location: app.example.com/auth/callback?code=auth_code_xxx |
| | | |
| 6. Shell sends code to BFF Worker | |
|--------------------->|--------------------->| |
| | POST /auth/token | |
| | { code: "xxx" } | |
| | | |
| | 7. BFF exchanges code for tokens |
| | |--------------------->|
| | | POST /sso/token |
| | | { code, client_id, |
| | | client_secret } |
| | |<---------------------|
| | | { access_token, |
| | | refresh_token } |
| | | |
| 8. BFF sets HttpOnly, Secure, SameSite=Strict cookie |
|<-----------------------------------------------------------------|
| Set-Cookie: session=<encrypted>; HttpOnly; Secure; SameSite=Strict
| | | |
| 9. Subsequent requests include cookie automatically |
|--------------------->|--------------------->| |
| Cookie: session=<encrypted> | |
| | | |
| 10. API Gateway validates JWT from cookie on every request |
| | | |
Decisiones clave de seguridad en este flujo:
- El intercambio de código ocurre del lado del servidor (paso 7): El authorization code se intercambia por tokens en el BFF Worker, nunca en JavaScript del lado del cliente. El
client_secretnunca sale del Worker. - Los tokens nunca llegan al navegador (paso 8): El navegador recibe únicamente una cookie de sesión cifrada. El JWT real y el refresh token se cifran dentro del valor de la cookie o se almacenan en Worker KV con la cookie conteniendo solo un ID de sesión.
- Transporte basado en cookies (paso 9): Usar cookies en lugar de headers Authorization hace que la autenticación sea automática. Cada petición a
*.example.comincluye la cookie sin que el código del cliente necesite gestionar headers.
Validación de JWT en el API Gateway
Uso de la librería jose
Cloudflare Workers se ejecutan sobre el motor JavaScript V8 sin acceso a los módulos nativos de Node.js como crypto. La librería jose (fijada en ^6.1.3) está diseñada específicamente para esta restricción: usa exclusivamente la Web Crypto API, lo que la hace totalmente compatible con Cloudflare Workers, Deno y entornos de navegador.
Notas de migración a jose v6:
createRemoteJWKSetusafetchnativo en lugar denode:httppara la obtención de claves, lo que la hace totalmente compatible con edge runtimes sin polyfills.- Los objetos de clave son instancias de
CryptoKey(Web Crypto API) en lugar deKeyObjectde Node.js. Este es un breaking change respecto a v4 -- cualquier código que haga type-check deKeyObjectdebe actualizarse. importJWKdevuelveCryptoKeydirectamente, eliminando la necesidad de castsas KeyLikeen la mayoría de los casos.
El Worker del API Gateway valida el JWT extraído de la cookie de sesión en cada petición entrante:
// workers/api-gateway/src/auth/jwt.ts
// package.json: "jose": "^6.1.3"
import { jwtVerify, createRemoteJWKSet, type JWTPayload } from 'jose';
// createRemoteJWKSet with error handling for network failures.
// In jose v6, this uses native fetch internally. Network errors (DNS
// resolution failures, timeouts, connection resets) will surface as
// fetch-level exceptions that must be caught at the call site.
let JWKS: ReturnType<typeof createRemoteJWKSet>;
function getJWKS(): ReturnType<typeof createRemoteJWKSet> {
if (!JWKS) {
JWKS = createRemoteJWKSet(
new URL(`https://api.workos.com/sso/jwks/${WORKOS_CLIENT_ID}`),
{
cacheMaxAge: 600_000, // Cache JWKS for 10 minutes
cooldownDuration: 30_000, // Wait 30s before re-fetching after a failure
}
);
}
return JWKS;
}
export interface WorkOSJWTPayload extends JWTPayload {
sub: string; // User ID
org_id: string; // Organization ID
role: string; // User role within the organization
permissions: string[]; // Granted permissions
}
export async function validateToken(token: string): Promise<WorkOSJWTPayload> {
try {
const { payload } = await jwtVerify(token, getJWKS(), {
issuer: 'https://api.workos.com',
audience: WORKOS_CLIENT_ID,
});
// Validate required claims are present
if (!payload.sub || !payload.org_id) {
throw new Error('JWT missing required claims: sub, org_id');
}
return payload as WorkOSJWTPayload;
} catch (error) {
// Distinguish network errors from validation errors so callers
// can decide whether to retry or reject immediately.
if (error instanceof TypeError && error.message.includes('fetch')) {
// Network-level failure: DNS resolution, timeout, connection reset.
// The JWKS endpoint is unreachable. Log and rethrow with context.
console.error('JWKS fetch failed (network error):', error.message);
throw new JWKSNetworkError(
`Unable to reach JWKS endpoint: ${error.message}`,
{ cause: error }
);
}
throw error; // Re-throw validation errors (expired, bad signature, etc.)
}
}
/**
* Custom error class for JWKS network failures. Callers can check
* `instanceof JWKSNetworkError` to distinguish transient network issues
* from permanent validation failures.
*/
export class JWKSNetworkError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = 'JWKSNetworkError';
}
}
El middleware que aplica esta validación a cada petición de API:
// workers/api-gateway/src/middleware/auth.ts
import { validateToken, type WorkOSJWTPayload } from '../auth/jwt';
import { decryptSessionCookie } from '../auth/session';
export interface AuthenticatedRequest extends Request {
auth: WorkOSJWTPayload;
}
export async function authMiddleware(
request: Request,
env: Env
): Promise<AuthenticatedRequest | Response> {
// Extract session cookie
const cookieHeader = request.headers.get('Cookie') ?? '';
const sessionCookie = parseCookie(cookieHeader, 'session');
if (!sessionCookie) {
return new Response(JSON.stringify({ error: 'Authentication required' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
try {
// Decrypt the session cookie to extract the JWT
const token = await decryptSessionCookie(sessionCookie, env.SESSION_SECRET);
// Validate the JWT against WorkOS JWKS
const payload = await validateToken(token);
// Attach auth context to the request for downstream handlers
const authenticatedRequest = request as AuthenticatedRequest;
authenticatedRequest.auth = payload;
return authenticatedRequest;
} catch (error) {
console.error('Auth validation failed:', error);
return new Response(JSON.stringify({ error: 'Invalid or expired token' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
}
function parseCookie(cookieHeader: string, name: string): string | null {
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
}
Caché de JWKS en KV
Por defecto, createRemoteJWKSet obtiene el JWKS de WorkOS en la primera llamada de validación y lo almacena en caché en memoria. Sin embargo, Cloudflare Workers no tienen caché en memoria persistente entre isolates. Cada petición puede ejecutarse en un isolate diferente, provocando obtenciones repetidas de JWKS y sumando 100-300ms de latencia por validación en frío.
La solución es almacenar las claves JWKS en caché en Cloudflare KV con un TTL corto:
// workers/api-gateway/src/auth/jwks-cache.ts
// package.json: "jose": "^6.1.3"
import {
importJWK,
jwtVerify,
type JWK,
type JWTPayload,
} from 'jose';
const JWKS_CACHE_KEY = 'workos:jwks';
const JWKS_CACHE_TTL_SECONDS = 300; // 5 minutes
interface CachedJWKS {
keys: JWK[];
cachedAt: number;
}
export async function validateTokenWithCache(
token: string,
env: Env
): Promise<JWTPayload> {
// Step 1: Try to validate with cached JWKS
const cached = await getCachedJWKS(env);
if (cached) {
try {
return await validateWithKeys(token, cached.keys, env);
} catch (error) {
// Cached key might be rotated. Fall through to re-fetch.
console.warn('Cached JWKS validation failed, re-fetching:', error);
}
}
// Step 2: Fetch fresh JWKS from WorkOS
const freshKeys = await fetchAndCacheJWKS(env);
return await validateWithKeys(token, freshKeys, env);
}
async function getCachedJWKS(env: Env): Promise<CachedJWKS | null> {
const raw = await env.AUTH_KV.get(JWKS_CACHE_KEY, 'json');
if (!raw) return null;
const cached = raw as CachedJWKS;
const ageSeconds = (Date.now() - cached.cachedAt) / 1000;
// Return null if cache is expired (even though KV has its own TTL,
// we check here for extra safety)
if (ageSeconds > JWKS_CACHE_TTL_SECONDS) return null;
return cached;
}
async function fetchAndCacheJWKS(env: Env): Promise<JWK[]> {
let response: Response;
try {
response = await fetch(
`https://api.workos.com/sso/jwks/${env.WORKOS_CLIENT_ID}`,
{ signal: AbortSignal.timeout(5_000) } // 5-second timeout
);
} catch (error) {
// Network-level failure: DNS resolution, timeout, connection reset.
// Attempt to return stale cached keys as a fallback.
const staleCache = await env.AUTH_KV.get(JWKS_CACHE_KEY, 'json');
if (staleCache) {
console.warn(
'JWKS fetch failed, falling back to stale cached keys:',
(error as Error).message
);
return (staleCache as CachedJWKS).keys;
}
throw new Error(
`JWKS endpoint unreachable and no cached keys available: ${(error as Error).message}`
);
}
if (!response.ok) {
// HTTP error (4xx/5xx). Fall back to stale cache if available.
const staleCache = await env.AUTH_KV.get(JWKS_CACHE_KEY, 'json');
if (staleCache) {
console.warn(
`JWKS fetch returned ${response.status}, falling back to stale cached keys`
);
return (staleCache as CachedJWKS).keys;
}
throw new Error(`Failed to fetch JWKS: ${response.status}`);
}
const jwks = (await response.json()) as { keys: JWK[] };
// Cache in KV with TTL
const cacheEntry: CachedJWKS = {
keys: jwks.keys,
cachedAt: Date.now(),
};
await env.AUTH_KV.put(JWKS_CACHE_KEY, JSON.stringify(cacheEntry), {
expirationTtl: JWKS_CACHE_TTL_SECONDS + 60, // KV TTL slightly longer than logical TTL
});
return jwks.keys;
}
async function validateWithKeys(
token: string,
keys: JWK[],
env: Env
): Promise<JWTPayload> {
// Extract the key ID from the JWT header to find the matching key
const headerPayload = token.split('.')[0];
const header = JSON.parse(atob(headerPayload));
const kid = header.kid;
const matchingKey = keys.find((k) => k.kid === kid);
if (!matchingKey) {
throw new Error(`No matching key found for kid: ${kid}`);
}
// In jose v6, importJWK returns CryptoKey (Web Crypto API) instead of KeyObject
const publicKey: CryptoKey = (await importJWK(matchingKey, 'RS256')) as CryptoKey;
const { payload } = await jwtVerify(token, publicKey, {
issuer: 'https://api.workos.com',
audience: env.WORKOS_CLIENT_ID,
});
return payload;
}
Impacto de rendimiento de la caché JWKS:
| Escenario | Latencia |
|---|---|
| Sin caché (cold start, primera petición) | 150-300ms (fetch de red a WorkOS) |
| Cacheado en KV (petición típica) | 1-2ms (lectura KV + verificación criptográfica) |
| Cache miss por rotación de claves | 150-300ms (re-fetch puntual, luego cacheado) |
Tras la primera población de la caché, la validación de JWT añade menos de 2ms al procesamiento de peticiones. La rotación de claves se maneja de forma fluida: si la clave cacheada no coincide con el kid del token, se dispara un fetch de JWKS fresco y se actualiza la caché.
Gestión de sesiones
Sesiones gestionadas por el BFF
Un principio de seguridad fundamental de esta arquitectura es que el navegador nunca maneja directamente tokens de autenticación. No se almacenan access tokens, refresh tokens ni JWTs en localStorage, sessionStorage ni en variables JavaScript accesibles. Toda la gestión del ciclo de vida de tokens ocurre en el BFF (Backend for Frontend) Worker.
// workers/bff/src/auth/session.ts
const SESSION_COOKIE_NAME = 'session';
// IMPORTANT: Session cookie Max-Age must be aligned with the refresh token
// lifetime, NOT the access token lifetime. Access tokens are short-lived
// (15-30 min) and are refreshed transparently by the BFF. The cookie must
// survive long enough for the refresh token to be used. WorkOS refresh
// tokens default to 30 days, so we set the cookie to 30 days to match.
// If the cookie expires before the refresh token, the user is logged out
// prematurely. If the cookie outlives the refresh token, the BFF will
// detect the expired refresh token and return 401, triggering re-login.
const SESSION_MAX_AGE = 60 * 60 * 24 * 30; // 30 days (aligned with refresh token lifetime)
interface SessionData {
accessToken: string;
refreshToken: string;
expiresAt: number; // Unix timestamp (seconds)
userId: string;
organizationId: string;
}
/**
* Encrypts session data and returns a Set-Cookie header value.
* Uses AES-GCM with a 256-bit key derived from the SESSION_SECRET.
*/
export async function createSessionCookie(
session: SessionData,
secret: string
): Promise<string> {
const plaintext = JSON.stringify(session);
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(secret);
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(plaintext)
);
// Combine IV + ciphertext and base64url-encode
const combined = new Uint8Array(iv.length + new Uint8Array(ciphertext).length);
combined.set(iv);
combined.set(new Uint8Array(ciphertext), iv.length);
const encoded = btoa(String.fromCharCode(...combined))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return [
`${SESSION_COOKIE_NAME}=${encoded}`,
`HttpOnly`,
`Secure`,
`SameSite=Strict`,
`Path=/`,
`Domain=.example.com`,
`Max-Age=${SESSION_MAX_AGE}`,
].join('; ');
}
/**
* Decrypts a session cookie value and returns the session data.
*/
export async function decryptSession(
cookieValue: string,
secret: string
): Promise<SessionData> {
// Base64url decode
const padded = cookieValue.replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(padded);
const combined = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
combined[i] = binary.charCodeAt(i);
}
const iv = combined.slice(0, 12);
const ciphertext = combined.slice(12);
const key = await deriveKey(secret);
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
ciphertext
);
return JSON.parse(new TextDecoder().decode(plaintext));
}
async function deriveKey(secret: string): Promise<CryptoKey> {
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
'HKDF',
false,
['deriveKey']
);
return crypto.subtle.deriveKey(
{
name: 'HKDF',
hash: 'SHA-256',
salt: new TextEncoder().encode('authkit-session'),
info: new TextEncoder().encode('session-encryption'),
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
El endpoint de intercambio de tokens en el BFF Worker:
// workers/bff/src/routes/auth.ts
import { createSessionCookie } from '../auth/session';
export async function handleTokenExchange(
request: Request,
env: Env
): Promise<Response> {
const { code } = (await request.json()) as { code: string };
if (!code) {
return new Response(JSON.stringify({ error: 'Missing authorization code' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// Exchange authorization code for tokens (server-side only)
const tokenResponse = await fetch('https://api.workos.com/sso/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: env.WORKOS_CLIENT_ID,
client_secret: env.WORKOS_CLIENT_SECRET, // Never exposed to browser
grant_type: 'authorization_code',
code,
}),
});
if (!tokenResponse.ok) {
const error = await tokenResponse.text();
console.error('Token exchange failed:', error);
return new Response(JSON.stringify({ error: 'Authentication failed' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const tokens = (await tokenResponse.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
user: { id: string; organization_id: string };
};
// Create encrypted session cookie
const cookie = await createSessionCookie(
{
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: Math.floor(Date.now() / 1000) + tokens.expires_in,
userId: tokens.user.id,
organizationId: tokens.user.organization_id,
},
env.SESSION_SECRET
);
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Set-Cookie': cookie,
},
});
}
Flujo de renovación de tokens
Los access tokens emitidos por WorkOS tienen un tiempo de vida corto (típicamente 15-30 minutos). El BFF Worker gestiona la renovación de forma transparente para que el cliente nunca encuentre un token expirado.
// workers/bff/src/middleware/refresh.ts
import { decryptSession, createSessionCookie, type SessionData } from '../auth/session';
const REFRESH_BUFFER_SECONDS = 60; // Refresh 60 seconds before actual expiry
export async function refreshMiddleware(
request: Request,
env: Env
): Promise<{ session: SessionData; newCookie?: string }> {
const cookieHeader = request.headers.get('Cookie') ?? '';
const sessionCookie = parseCookie(cookieHeader, 'session');
if (!sessionCookie) {
throw new AuthError('No session cookie', 401);
}
let session: SessionData;
try {
session = await decryptSession(sessionCookie, env.SESSION_SECRET);
} catch {
throw new AuthError('Invalid session', 401);
}
const now = Math.floor(Date.now() / 1000);
const isExpiringSoon = session.expiresAt - now < REFRESH_BUFFER_SECONDS;
if (!isExpiringSoon) {
// Token is still valid, proceed
return { session };
}
// Token is expired or expiring soon, attempt refresh
try {
const refreshResponse = await fetch('https://api.workos.com/user_management/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: env.WORKOS_CLIENT_ID,
client_secret: env.WORKOS_CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token: session.refreshToken,
}),
});
if (!refreshResponse.ok) {
// Refresh token is also expired or revoked
throw new AuthError('Session expired, please log in again', 401);
}
const tokens = (await refreshResponse.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
};
const newSession: SessionData = {
...session,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: now + tokens.expires_in,
};
const newCookie = await createSessionCookie(newSession, env.SESSION_SECRET);
return { session: newSession, newCookie };
} catch (error) {
if (error instanceof AuthError) throw error;
console.error('Token refresh failed:', error);
throw new AuthError('Session expired, please log in again', 401);
}
}
class AuthError extends Error {
constructor(
message: string,
public status: number
) {
super(message);
}
}
function parseCookie(cookieHeader: string, name: string): string | null {
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
}
Aplicación del middleware de renovación en el fetch handler del BFF Worker:
// workers/bff/src/index.ts
import { refreshMiddleware } from './middleware/refresh';
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Public endpoints (no auth required)
if (url.pathname === '/auth/token') {
return handleTokenExchange(request, env);
}
// All other endpoints require a valid session
try {
const { session, newCookie } = await refreshMiddleware(request, env);
// Route to the appropriate handler with auth context
let response = await routeRequest(request, env, session);
// If the token was refreshed, set the updated cookie on the response
if (newCookie) {
response = new Response(response.body, response);
response.headers.append('Set-Cookie', newCookie);
}
return response;
} catch (error) {
if (error instanceof AuthError) {
return new Response(JSON.stringify({ error: error.message }), {
status: error.status,
headers: { 'Content-Type': 'application/json' },
});
}
throw error;
}
},
};
Dominio compartido para cookies
Para que la autenticación basada en cookies funcione sin fricciones entre la shell app, los MFEs y los Workers backend, todos los servicios deben compartir un dominio padre común. La cookie se configura con Domain=.example.com, lo que la hace disponible para todos los subdominios.
example.com (root domain)
|
+-- app.example.com Shell app (served by Cloudflare Pages / Worker)
| - Hosts the shell SPA
| - Loads MFE bundles from cdn.example.com
|
+-- cdn.example.com CDN for static assets
| - MFE JavaScript bundles
| - Shared design system assets
| - Served by Cloudflare R2 + CDN
|
+-- api.example.com API Gateway Worker
| - Validates JWT from session cookie
| - Routes to domain-specific service Workers
|
+-- auth.example.com Custom domain for WorkOS AuthKit
| - CNAME to WorkOS
| - Hosts the login/signup UI
|
+-- bff.example.com BFF Worker
- Token exchange
- Session management
- Token refresh
Atributos de la cookie y su propósito:
| Atributo | Valor | Razón |
|---|---|---|
HttpOnly | (activado) | Impide el acceso de JavaScript a la cookie, mitigando el robo de tokens por XSS |
Secure | (activado) | La cookie solo se envía por HTTPS |
SameSite | Strict | La cookie no se envía en peticiones cross-site, mitigando CSRF |
Domain | .example.com | Compartida entre todos los subdominios |
Path | / | Disponible en todas las rutas |
Max-Age | 2592000 (30 días) | Alineado con el tiempo de vida del refresh token; los access tokens se renuevan de forma transparente por el BFF |
Multi-tenancy basado en organizaciones
Organizaciones en WorkOS
WorkOS proporciona soporte de primera clase para multi-tenancy a través de su modelo de Organizations. Cada cliente (tenant) de la plataforma se mapea a una Organization de WorkOS. Características clave:
- Los usuarios pueden pertenecer a múltiples organizaciones: Un consultor podría tener acceso a tres cuentas de clientes distintas. Se autentica una vez y puede alternar entre organizaciones sin re-autenticarse.
- El JWT incluye el claim
org_id: Cada access token contiene la organización activa, permitiendo el aislamiento de datos en la capa de API. - Roles y permisos con alcance de organización: Un usuario puede ser
adminen una organización ymemberen otra.
El API Gateway usa el org_id del JWT para imponer el aislamiento de datos:
// workers/api-gateway/src/middleware/tenant.ts
import type { AuthenticatedRequest } from './auth';
/**
* Extracts the organization context from the authenticated request
* and injects it into downstream service requests.
*/
export function tenantMiddleware(request: AuthenticatedRequest): Headers {
const headers = new Headers(request.headers);
// Inject tenant context for downstream services
headers.set('X-Tenant-ID', request.auth.org_id);
headers.set('X-User-ID', request.auth.sub);
headers.set('X-User-Role', request.auth.role);
return headers;
}
Los Workers de servicios downstream usan el header X-Tenant-ID para acotar todas las consultas a base de datos:
// workers/projects-service/src/db.ts
export async function getProjects(tenantId: string, db: D1Database) {
// Every query is scoped to the organization
const result = await db
.prepare('SELECT * FROM projects WHERE org_id = ? ORDER BY updated_at DESC')
.bind(tenantId)
.all();
return result.results;
}
Cambio de organización
La shell app proporciona un selector de organización en la navegación global. Cuando un usuario cambia de organización, la sesión se actualiza y todos los MFEs reciben notificación.
// apps/shell/src/components/OrgSwitcher.tsx
import { useState, useEffect } from 'react';
import { useShellAuth } from '../contexts/AuthContext';
interface Organization {
id: string;
name: string;
slug: string;
logoUrl: string | null;
}
export function OrgSwitcher() {
const { user } = useShellAuth();
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [activeOrgId, setActiveOrgId] = useState<string | null>(
user?.organizationId ?? null
);
useEffect(() => {
// Fetch user's organizations from BFF
fetch('/api/user/organizations', { credentials: 'include' })
.then((res) => res.json())
.then((data) => setOrganizations(data.organizations));
}, []);
async function switchOrganization(orgId: string) {
// Tell the BFF to update the session with the new org context
const response = await fetch('/api/auth/switch-org', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ organizationId: orgId }),
});
if (!response.ok) {
console.error('Failed to switch organization');
return;
}
setActiveOrgId(orgId);
// Notify all MFEs about the organization change via CustomEvent
window.dispatchEvent(
new CustomEvent('shell:org-changed', {
detail: {
organizationId: orgId,
organization: organizations.find((o) => o.id === orgId),
},
})
);
// Refresh the current page to reload data for the new org
window.location.reload();
}
const activeOrg = organizations.find((o) => o.id === activeOrgId);
return (
<div className="relative">
<button
className="flex items-center gap-2 px-3 py-2 rounded-md hover:bg-neutral-100"
aria-label="Switch organization"
>
{activeOrg?.logoUrl && (
<img
src={activeOrg.logoUrl}
alt=""
className="w-6 h-6 rounded-full"
/>
)}
<span className="text-sm font-medium">{activeOrg?.name ?? 'Select org'}</span>
</button>
{/* Dropdown with org list */}
<ul className="absolute top-full left-0 mt-1 w-64 bg-white border rounded-lg shadow-lg">
{organizations.map((org) => (
<li key={org.id}>
<button
className={`w-full text-left px-4 py-2 hover:bg-neutral-50 ${
org.id === activeOrgId ? 'bg-neutral-100 font-semibold' : ''
}`}
onClick={() => switchOrganization(org.id)}
>
{org.name}
</button>
</li>
))}
</ul>
</div>
);
}
El endpoint del BFF Worker que gestiona el cambio de organización:
// workers/bff/src/routes/auth.ts
export async function handleOrgSwitch(
request: Request,
env: Env,
session: SessionData
): Promise<Response> {
const { organizationId } = (await request.json()) as { organizationId: string };
// Request a new access token scoped to the target organization
const response = await fetch('https://api.workos.com/user_management/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: env.WORKOS_CLIENT_ID,
client_secret: env.WORKOS_CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token: session.refreshToken,
organization_id: organizationId,
}),
});
if (!response.ok) {
return new Response(JSON.stringify({ error: 'Failed to switch organization' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
const tokens = (await response.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
};
const newSession: SessionData = {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: Math.floor(Date.now() / 1000) + tokens.expires_in,
userId: session.userId,
organizationId,
};
const cookie = await createSessionCookie(newSession, env.SESSION_SECRET);
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Set-Cookie': cookie,
},
});
}
Contexto de autenticación para MFEs
Pasar autenticación a componentes remotos
Existen tres patrones para hacer disponible el contexto de autenticación en los micro frontends. Cada uno tiene sus compromisos.
Opción 1: React Context (la shell proporciona contexto de autenticación a través de un provider compartido)
La shell envuelve cada MFE en un provider de contexto de autenticación. Los MFEs importan el tipo de contexto y lo consumen mediante un hook.
// packages/shared-types/src/auth.ts
export interface MFEAuthContext {
user: {
id: string;
email: string;
firstName: string | null;
lastName: string | null;
organizationId: string | null;
} | null;
permissions: string[];
isAuthenticated: boolean;
}
// apps/shell/src/components/MFEWrapper.tsx
import { createContext, useContext } from 'react';
import { useShellAuth } from '../contexts/AuthContext';
import type { MFEAuthContext } from '@platform/shared-types';
const MFEAuthContext = createContext<MFEAuthContext | null>(null);
export function MFEWrapper({ children }: { children: React.ReactNode }) {
const { user, isAuthenticated } = useShellAuth();
const permissions = usePermissions(); // Fetched from BFF
return (
<MFEAuthContext.Provider value={{ user, permissions, isAuthenticated }}>
{children}
</MFEAuthContext.Provider>
);
}
// Used inside any MFE:
export function useMFEAuth(): MFEAuthContext {
const ctx = useContext(MFEAuthContext);
if (!ctx) throw new Error('useMFEAuth must be used within MFEWrapper');
return ctx;
}
Compromiso: Requiere un acuerdo de tipo de contexto compartido entre la shell y los MFEs. Acopla fuertemente los MFEs a la implementación de autenticación de la shell.
Opción 2: Props pasadas al componente raíz del MFE
La shell pasa los datos de autenticación como props al renderizar el componente raíz del MFE.
// apps/shell/src/components/MFELoader.tsx
import { Suspense, lazy } from 'react';
import { useShellAuth } from '../contexts/AuthContext';
const DashboardApp = lazy(() => import('dashboard/App'));
export function DashboardMFE() {
const { user, isAuthenticated } = useShellAuth();
const permissions = usePermissions();
return (
<Suspense fallback={<LoadingSkeleton />}>
<DashboardApp
user={user}
permissions={permissions}
isAuthenticated={isAuthenticated}
/>
</Suspense>
);
}
// Inside the dashboard MFE:
// apps/dashboard/src/App.tsx
interface DashboardAppProps {
user: { id: string; email: string } | null;
permissions: string[];
isAuthenticated: boolean;
}
export default function DashboardApp({ user, permissions }: DashboardAppProps) {
// MFE has auth data via props, no need for its own auth provider
return <DashboardLayout user={user} permissions={permissions} />;
}
Compromiso: Simple y explícito. Cada MFE declara qué datos de autenticación necesita. Sin embargo, cada llamada API desde el MFE sigue necesitando pasar tokens de alguna forma.
Opción 3: El BFF gestiona la autenticación implícitamente vía cookies (Recomendada)
Los MFEs hacen llamadas API directamente al BFF Worker. La cookie de sesión se incluye automáticamente por el navegador (porque todos los servicios comparten .example.com). Los MFEs no necesitan saber nada sobre tokens.
// Inside any MFE — no auth code needed
// apps/dashboard/src/api/projects.ts
export async function fetchProjects(): Promise<Project[]> {
const response = await fetch('https://api.example.com/v1/projects', {
credentials: 'include', // Sends the session cookie automatically
});
if (response.status === 401) {
// Session expired — dispatch event for shell to handle
window.dispatchEvent(new CustomEvent('shell:auth-expired'));
throw new Error('Session expired');
}
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
}
La shell escucha el evento shell:auth-expired y redirige al login:
// apps/shell/src/hooks/useAuthExpiredListener.ts
import { useEffect } from 'react';
import { useShellAuth } from '../contexts/AuthContext';
export function useAuthExpiredListener() {
const { signIn } = useShellAuth();
useEffect(() => {
function handleExpired() {
// Store current URL for post-login redirect
sessionStorage.setItem(
'auth:returnTo',
window.location.pathname + window.location.search
);
signIn();
}
window.addEventListener('shell:auth-expired', handleExpired);
return () => window.removeEventListener('shell:auth-expired', handleExpired);
}, [signIn]);
}
Enfoque recomendado: La Opción 3 es el patrón recomendado. Los MFEs no necesitan preocuparse por la autenticación en las llamadas API. La cookie de sesión fluye automáticamente con cada petición a *.example.com. La shell sigue pasando información básica del usuario (nombre, email, avatar) y permisos vía props (Opción 2) para el renderizado de UI, pero los MFEs nunca manejan tokens.
Permisos específicos por MFE
WorkOS soporta control de acceso basado en roles (RBAC) con roles y permisos personalizados. La shell obtiene los permisos del usuario para su organización actual y los pasa a los MFEs.
// packages/shared-utils/src/permissions.ts
/**
* Permission check helper used by MFEs to conditionally render UI elements.
*/
export function hasPermission(
userPermissions: string[],
required: string | string[]
): boolean {
const requiredList = Array.isArray(required) ? required : [required];
return requiredList.every((p) => userPermissions.includes(p));
}
export function hasAnyPermission(
userPermissions: string[],
required: string[]
): boolean {
return required.some((p) => userPermissions.includes(p));
}
Un componente React para renderizado basado en permisos:
// packages/shared-ui/src/components/Authorize.tsx
import { type ReactNode } from 'react';
import { hasPermission, hasAnyPermission } from '@platform/shared-utils';
interface AuthorizeProps {
/** User's current permissions */
permissions: string[];
/** Required permission(s). All must be present unless `mode` is 'any'. */
required: string | string[];
/** If 'any', at least one permission must match. Default: 'all'. */
mode?: 'all' | 'any';
/** Content to render if authorized */
children: ReactNode;
/** Optional fallback if unauthorized */
fallback?: ReactNode;
}
export function Authorize({
permissions,
required,
mode = 'all',
children,
fallback = null,
}: AuthorizeProps) {
const requiredList = Array.isArray(required) ? required : [required];
const authorized =
mode === 'any'
? hasAnyPermission(permissions, requiredList)
: hasPermission(permissions, requiredList);
return authorized ? <>{children}</> : <>{fallback}</>;
}
Uso dentro de un MFE:
// apps/settings/src/pages/TeamSettings.tsx
import { Authorize } from '@platform/shared-ui';
interface TeamSettingsProps {
permissions: string[];
}
export function TeamSettings({ permissions }: TeamSettingsProps) {
return (
<div>
<h1>Team Settings</h1>
{/* Visible to all authenticated users */}
<TeamMemberList />
{/* Only visible to users with invite permission */}
<Authorize permissions={permissions} required="team:invite">
<InviteMemberButton />
</Authorize>
{/* Only visible to admins */}
<Authorize
permissions={permissions}
required={['team:manage', 'team:delete']}
mode="all"
fallback={<p className="text-neutral-500">Contact an admin to manage roles.</p>}
>
<RoleManagementPanel />
</Authorize>
{/* Visible to users with either billing or admin permission */}
<Authorize
permissions={permissions}
required={['billing:read', 'org:admin']}
mode="any"
>
<BillingOverviewCard />
</Authorize>
</div>
);
}
Permisos definidos por la plataforma (registrados en el dashboard de WorkOS):
| Permiso | Descripción |
|---|---|
org:admin | Acceso completo de administración de la organización |
team:read | Ver miembros del equipo |
team:invite | Invitar nuevos miembros al equipo |
team:manage | Gestionar roles y eliminar miembros |
team:delete | Eliminar miembros del equipo |
billing:read | Ver información de facturación |
billing:manage | Actualizar métodos de pago y planes |
projects:read | Ver proyectos |
projects:write | Crear y editar proyectos |
projects:delete | Eliminar proyectos |
settings:read | Ver ajustes de la organización |
settings:write | Modificar ajustes de la organización |
Consideraciones de seguridad
Protección CSRF
Las cookies SameSite=Strict proporcionan una protección CSRF fuerte por defecto: el navegador no enviará la cookie en ninguna petición cross-site (incluyendo navegaciones de nivel superior desde enlaces externos). Como defensa en profundidad adicional, el API Gateway requiere un header personalizado en todas las peticiones que modifican estado:
// workers/api-gateway/src/middleware/csrf.ts
export function csrfMiddleware(request: Request): Response | null {
// Only apply to state-changing methods
const method = request.method.toUpperCase();
if (['GET', 'HEAD', 'OPTIONS'].includes(method)) {
return null; // Pass through
}
// Require a custom header that cannot be set by cross-origin forms
const csrfHeader = request.headers.get('X-Requested-With');
if (csrfHeader !== 'XMLHttpRequest') {
return new Response(JSON.stringify({ error: 'CSRF validation failed' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
// Verify Origin header matches allowed origins
const origin = request.headers.get('Origin');
const allowedOrigins = ['https://app.example.com', 'https://bff.example.com'];
if (origin && !allowedOrigins.includes(origin)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
return null; // Pass through
}
Mitigación de XSS
- Cookies HttpOnly: Los tokens se almacenan en cookies con el flag
HttpOnly, haciéndolos inaccesibles para JavaScript. Incluso si existe una vulnerabilidad XSS, el atacante no puede exfiltrar el token de sesión. - Content Security Policy: La shell app sirve un header CSP estricto que impide scripts inline y restringe las fuentes de scripts.
// workers/shell/src/middleware/csp.ts
export function cspHeaders(): Record<string, string> {
return {
'Content-Security-Policy': [
"default-src 'self'",
"script-src 'self' https://cdn.example.com",
"style-src 'self' 'unsafe-inline' https://cdn.example.com", // Inline styles for CSS-in-JS
"img-src 'self' https://cdn.example.com https://*.workos.com data:",
"connect-src 'self' https://api.example.com https://bff.example.com https://*.workos.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join('; '),
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'strict-origin-when-cross-origin',
};
}
Política de almacenamiento de tokens
Los tokens nunca se almacenan en:
localStorage(persiste entre sesiones, accesible a cualquier JS del dominio)sessionStorage(accesible a cualquier JS del dominio)- Variables JavaScript en código del lado del cliente (accesibles vía XSS)
- Parámetros o fragmentos de URL (se registran en logs del servidor, historial del navegador, headers de referrer)
Los tokens solo se almacenan en:
- Cookies
HttpOnlycifradas (inaccesibles para JavaScript) - Worker KV (almacén de sesiones del lado del servidor, si el tamaño de la cookie excede los límites)
Configuración CORS
El API Gateway aplica una política CORS estricta:
// workers/api-gateway/src/middleware/cors.ts
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://bff.example.com',
]);
export function corsHeaders(request: Request): Record<string, string> {
const origin = request.headers.get('Origin') ?? '';
if (!ALLOWED_ORIGINS.has(origin)) {
return {}; // No CORS headers = browser blocks the response
}
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-Requested-With',
'Access-Control-Allow-Credentials': 'true', // Required for cookie-based auth
'Access-Control-Max-Age': '86400', // Cache preflight for 24 hours
};
}
Limitación de tasa
Los endpoints de autenticación tienen limitación de tasa para prevenir ataques de fuerza bruta:
// workers/bff/src/middleware/rate-limit.ts
interface RateLimitEntry {
count: number;
resetAt: number;
}
const AUTH_RATE_LIMITS = {
'/auth/token': { maxRequests: 10, windowSeconds: 60 },
'/auth/switch-org': { maxRequests: 20, windowSeconds: 60 },
'/auth/refresh': { maxRequests: 30, windowSeconds: 60 },
};
export async function rateLimitMiddleware(
request: Request,
env: Env
): Promise<Response | null> {
const url = new URL(request.url);
const limit = AUTH_RATE_LIMITS[url.pathname as keyof typeof AUTH_RATE_LIMITS];
if (!limit) return null; // No rate limit for this endpoint
const clientIp = request.headers.get('CF-Connecting-IP') ?? 'unknown';
const key = `rate-limit:${url.pathname}:${clientIp}`;
const entry = await env.AUTH_KV.get<RateLimitEntry>(key, 'json');
const now = Math.floor(Date.now() / 1000);
if (entry && entry.resetAt > now && entry.count >= limit.maxRequests) {
return new Response(JSON.stringify({ error: 'Too many requests' }), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(entry.resetAt - now),
},
});
}
// Update counter
const newEntry: RateLimitEntry = {
count: entry && entry.resetAt > now ? entry.count + 1 : 1,
resetAt: entry && entry.resetAt > now ? entry.resetAt : now + limit.windowSeconds,
};
await env.AUTH_KV.put(key, JSON.stringify(newEntry), {
expirationTtl: limit.windowSeconds + 10,
});
return null; // Allow the request
}
Registro de auditoría
Todos los eventos de autenticación se registran en una base de datos D1 para cumplimiento normativo y depuración:
// workers/bff/src/audit/log.ts
export interface AuthAuditEvent {
eventType:
| 'login_success'
| 'login_failure'
| 'logout'
| 'token_refresh'
| 'org_switch'
| 'session_expired';
userId: string | null;
organizationId: string | null;
ipAddress: string;
userAgent: string;
metadata: Record<string, string>;
timestamp: string;
}
export async function logAuthEvent(
event: AuthAuditEvent,
db: D1Database
): Promise<void> {
await db
.prepare(
`INSERT INTO auth_audit_log (event_type, user_id, organization_id, ip_address, user_agent, metadata, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.bind(
event.eventType,
event.userId,
event.organizationId,
event.ipAddress,
event.userAgent,
JSON.stringify(event.metadata),
event.timestamp
)
.run();
}
El esquema de la tabla de auditoría:
-- migrations/0003_auth_audit_log.sql
CREATE TABLE auth_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
user_id TEXT,
organization_id TEXT,
ip_address TEXT NOT NULL,
user_agent TEXT,
metadata TEXT DEFAULT '{}',
timestamp TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX idx_audit_user_id ON auth_audit_log(user_id);
CREATE INDEX idx_audit_org_id ON auth_audit_log(organization_id);
CREATE INDEX idx_audit_event_type ON auth_audit_log(event_type);
CREATE INDEX idx_audit_timestamp ON auth_audit_log(timestamp);
Estrategia de rotación de claves JWKS
WorkOS puede rotar sus claves de firma en cualquier momento. Cuando esto sucede, los JWTs firmados con la nueva clave fallarán la validación contra copias cacheadas del JWKS antiguo. El sistema debe gestionar la rotación de claves de forma fluida sin provocar interrupciones de autenticación.
Detección de rotación de claves:
El header del JWT contiene un campo kid (Key ID) que identifica qué clave se usó para firmar el token. Cuando llega un token con un kid que no coincide con ninguna clave del JWKS cacheado, esto señala un potencial evento de rotación de claves.
Estrategia: Intentar con caché, luego re-fetch, luego fallar
1. Receive JWT with kid="key_NEW"
2. Look up kid="key_NEW" in cached JWKS
3. If found → validate with cached key → done
4. If NOT found → fetch fresh JWKS from WorkOS endpoint
5. Look up kid="key_NEW" in fresh JWKS
6. If found → validate with fresh key → update cache → done
7. If NOT found in fresh JWKS → reject token (genuinely invalid)
Recomendaciones de TTL para la caché JWKS:
| Capa de caché | TTL | Justificación |
|---|---|---|
En memoria (caché integrada de createRemoteJWKSet) | 10 minutos | Ruta rápida; evita lecturas a KV para Workers activos |
| Cloudflare KV | 5 minutos (lógico) + 6 minutos (expiración KV) | Compartido entre isolates; TTL de KV ligeramente mayor para permitir fallback con datos obsoletos |
| Fallback con datos obsoletos (en caso de fallo de fetch) | Ilimitado (hasta el siguiente fetch exitoso) | Previene interrupciones cuando el endpoint de WorkOS es temporalmente inaccesible |
Monitorización de eventos de rotación de claves:
Se debe registrar cada cache miss que dispare un re-fetch de JWKS. Un pico repentino de re-fetches indica un evento de rotación de claves (o una mala configuración). El log de auditoría debe registrar estos eventos:
// workers/api-gateway/src/auth/jwks-cache.ts (addition to existing code)
async function logKeyRotationEvent(
oldKids: string[],
newKids: string[],
env: Env
): Promise<void> {
const addedKeys = newKids.filter((kid) => !oldKids.includes(kid));
const removedKeys = oldKids.filter((kid) => !newKids.includes(kid));
if (addedKeys.length > 0 || removedKeys.length > 0) {
console.warn('JWKS key rotation detected', {
addedKeys,
removedKeys,
timestamp: new Date().toISOString(),
});
}
}
Rotación de claves de cifrado de sesión
La cookie de sesión se cifra con una clave derivada de SESSION_SECRET. Cuando este secreto debe rotarse (p. ej., por un incidente de seguridad o como parte de la higiene habitual de claves), todas las sesiones activas cifradas con la clave antigua se volverían ilegibles, cerrando efectivamente la sesión de todos los usuarios.
Estrategia: Descifrar con cualquiera, cifrar con la más reciente
Se mantiene una lista ordenada de claves de cifrado. Siempre se cifran las sesiones nuevas con la clave más reciente. Al descifrar, se prueba cada clave en orden hasta que una funcione.
// workers/bff/src/auth/session-keys.ts
interface SessionKeyConfig {
/** Unique identifier for this key version */
version: number;
/** The secret used to derive the encryption key */
secret: string;
/** If true, this key is used for encrypting new sessions */
active: boolean;
}
/**
* Environment variable format (JSON array):
* SESSION_KEYS='[
* {"version": 2, "secret": "new-secret-here", "active": true},
* {"version": 1, "secret": "old-secret-here", "active": false}
* ]'
*
* Rotation procedure:
* 1. Add a new key with active: true
* 2. Set the old key to active: false (keep it in the list)
* 3. Deploy the change
* 4. After the session cookie Max-Age has elapsed (30 days), all sessions
* encrypted with the old key will have expired naturally
* 5. Remove the old key from the list
*/
function parseSessionKeys(keysJson: string): SessionKeyConfig[] {
const keys = JSON.parse(keysJson) as SessionKeyConfig[];
// Sort by version descending so the latest key is tried first
return keys.sort((a, b) => b.version - a.version);
}
function getActiveKey(keys: SessionKeyConfig[]): SessionKeyConfig {
const active = keys.find((k) => k.active);
if (!active) {
throw new Error('No active session encryption key configured');
}
return active;
}
/**
* Encrypt a session using the latest active key.
* The key version is prepended to the ciphertext so that decryption
* knows which key was used without trial-and-error.
*/
export async function encryptSession(
session: SessionData,
keysJson: string
): Promise<string> {
const keys = parseSessionKeys(keysJson);
const activeKey = getActiveKey(keys);
const key = await deriveKey(activeKey.secret);
const plaintext = JSON.stringify(session);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(plaintext)
);
// Prefix with key version (1 byte, supports up to 255 versions)
const combined = new Uint8Array(1 + iv.length + new Uint8Array(ciphertext).length);
combined[0] = activeKey.version;
combined.set(iv, 1);
combined.set(new Uint8Array(ciphertext), 1 + iv.length);
return base64UrlEncode(combined);
}
/**
* Decrypt a session. First tries the key version encoded in the ciphertext.
* Falls back to trying all keys if version lookup fails (handles legacy
* sessions encrypted before versioning was introduced).
*/
export async function decryptSession(
cookieValue: string,
keysJson: string
): Promise<SessionData> {
const combined = base64UrlDecode(cookieValue);
const keys = parseSessionKeys(keysJson);
// Try the versioned key first
const version = combined[0];
const versionedKey = keys.find((k) => k.version === version);
if (versionedKey) {
try {
return await decryptWithKey(combined.slice(1), versionedKey.secret);
} catch {
// Version byte might be coincidental in legacy sessions; fall through
}
}
// Fallback: try all keys (for legacy sessions without version prefix)
for (const keyConfig of keys) {
try {
return await decryptWithKey(combined, keyConfig.secret);
} catch {
continue; // Try next key
}
}
throw new Error('Session decryption failed with all available keys');
}
Cronograma de rotación:
| Día | Acción |
|---|---|
| Día 0 | Añadir nueva clave (versión N+1, active: true), establecer la clave antigua a active: false. Desplegar. |
| Día 0+ | Todas las sesiones nuevas se cifran con la clave N+1. Las sesiones existentes siguen descifrándose con la clave N. |
| Día 30+ | Todas las sesiones cifradas con la clave N han expirado (cookie Max-Age = 30 días). |
| Día 31 | Eliminar la clave N de la configuración. Desplegar. |
Prevención de condiciones de carrera en la renovación de tokens
Cuando hay múltiples pestañas del navegador abiertas o múltiples peticiones API se disparan simultáneamente, cada una puede detectar independientemente que el access token está expirando e intentar una renovación. Esto provoca múltiples peticiones de renovación concurrentes a WorkOS, lo que puede causar:
- Rechazo por reutilización del refresh token: Algunos proveedores de identidad invalidan un refresh token tras su primer uso. Si dos peticiones usan el mismo refresh token de forma concurrente, una tiene éxito y la otra falla, cerrando la sesión del usuario.
- Llamadas de red desperdiciadas: Múltiples peticiones de renovación redundantes añaden latencia y consumen presupuesto de limitación de tasa.
- Condición de carrera en cookies: Múltiples respuestas establecen cada una una nueva cookie de sesión; el navegador aplica la última, potencialmente sobrescribiendo un token más reciente con uno anterior.
Estrategia: Mutex distribuido vía KV con fallback a Durable Objects
// workers/bff/src/middleware/refresh-mutex.ts
const REFRESH_LOCK_TTL_SECONDS = 10; // Lock expires after 10 seconds
interface RefreshLockEntry {
lockedAt: number;
lockedBy: string; // Request ID for debugging
}
/**
* Acquires a refresh lock for the given session. Returns true if the lock
* was acquired, false if another request is already refreshing.
*/
async function acquireRefreshLock(
sessionId: string,
requestId: string,
env: Env
): Promise<boolean> {
const lockKey = `refresh-lock:${sessionId}`;
const existing = await env.AUTH_KV.get<RefreshLockEntry>(lockKey, 'json');
if (existing) {
const lockAge = (Date.now() - existing.lockedAt) / 1000;
if (lockAge < REFRESH_LOCK_TTL_SECONDS) {
// Another request holds the lock
return false;
}
// Lock expired, safe to take over
}
const entry: RefreshLockEntry = {
lockedAt: Date.now(),
lockedBy: requestId,
};
await env.AUTH_KV.put(lockKey, JSON.stringify(entry), {
expirationTtl: REFRESH_LOCK_TTL_SECONDS,
});
return true;
}
/**
* Releases the refresh lock after a successful or failed refresh.
*/
async function releaseRefreshLock(
sessionId: string,
env: Env
): Promise<void> {
await env.AUTH_KV.delete(`refresh-lock:${sessionId}`);
}
/**
* Enhanced refresh middleware with mutex to prevent concurrent refreshes.
*/
export async function refreshWithMutex(
request: Request,
env: Env,
session: SessionData
): Promise<{ session: SessionData; newCookie?: string }> {
const now = Math.floor(Date.now() / 1000);
const isExpiringSoon = session.expiresAt - now < REFRESH_BUFFER_SECONDS;
if (!isExpiringSoon) {
return { session };
}
const requestId = crypto.randomUUID();
const sessionId = session.userId; // Use userId as session identifier
const lockAcquired = await acquireRefreshLock(sessionId, requestId, env);
if (!lockAcquired) {
// Another request is refreshing. Wait briefly, then re-read the session.
// The other request will update the cookie, so use the current token
// (which is still valid for REFRESH_BUFFER_SECONDS).
console.log('Refresh lock held by another request, using current token');
return { session };
}
try {
const refreshedResult = await performTokenRefresh(session, env);
return refreshedResult;
} finally {
await releaseRefreshLock(sessionId, env);
}
}
Deduplicación del lado del cliente (para escenarios multi-pestaña):
Cuando la shell app usa BroadcastChannel para coordinar entre pestañas, solo una pestaña realiza la renovación y notifica a las demás:
// apps/shell/src/auth/refresh-coordinator.ts
const REFRESH_CHANNEL = 'auth:token-refresh';
let refreshInProgress = false;
let refreshPromise: Promise<void> | null = null;
const channel = new BroadcastChannel(REFRESH_CHANNEL);
channel.addEventListener('message', (event) => {
if (event.data.type === 'refresh-complete') {
// Another tab completed the refresh. The new cookie is already set
// by the BFF response in that tab. Our next request will use it.
refreshInProgress = false;
refreshPromise = null;
}
});
export async function coordinatedRefresh(
refreshFn: () => Promise<void>
): Promise<void> {
if (refreshInProgress && refreshPromise) {
// Deduplicate: wait for the in-flight refresh
return refreshPromise;
}
refreshInProgress = true;
refreshPromise = refreshFn()
.then(() => {
channel.postMessage({ type: 'refresh-complete' });
})
.finally(() => {
refreshInProgress = false;
refreshPromise = null;
});
return refreshPromise;
}
CORS dinámico para entornos de preview
En pipelines CI/CD que producen despliegues de preview/staging con URLs dinámicas (p. ej., https://pr-1234.preview.example.com o https://deploy-abc123.vercel.app), una lista estática de CORS permitidos es insuficiente. Cada despliegue de preview tiene una URL única que no se puede pre-registrar.
Estrategia: Validación de origen basada en patrones
// workers/api-gateway/src/middleware/cors.ts
// Static allowlist for production and staging
const STATIC_ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://bff.example.com',
'https://staging.example.com',
]);
// Regex patterns for dynamic preview environments.
// Each pattern must be carefully scoped to prevent overly permissive matching.
const DYNAMIC_ORIGIN_PATTERNS: RegExp[] = [
// Cloudflare Pages preview deployments: pr-<number>.preview.example.com
/^https:\/\/pr-\d+\.preview\.example\.com$/,
// Vercel preview deployments: deploy-<hash>.vercel.app
/^https:\/\/deploy-[a-z0-9]+\.vercel\.app$/,
// Branch-based previews: <branch-slug>.preview.example.com
/^https:\/\/[a-z0-9-]+\.preview\.example\.com$/,
];
function isAllowedOrigin(origin: string): boolean {
// Check static allowlist first (fastest path)
if (STATIC_ALLOWED_ORIGINS.has(origin)) {
return true;
}
// Check dynamic patterns for preview environments
return DYNAMIC_ORIGIN_PATTERNS.some((pattern) => pattern.test(origin));
}
export function corsHeaders(request: Request): Record<string, string> {
const origin = request.headers.get('Origin') ?? '';
if (!isAllowedOrigin(origin)) {
return {}; // No CORS headers = browser blocks the response
}
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-Requested-With',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '86400',
};
}
Consideraciones de seguridad para CORS dinámico:
- Nunca usar wildcard (
*) con credenciales:Access-Control-Allow-Credentials: truees incompatible conAccess-Control-Allow-Origin: *. El origen siempre debe reflejarse explícitamente. - Acotar los patrones regex estrictamente: Un patrón como
/^https:\/\/.*\.example\.com$/es peligrosamente amplio. Un atacante podría registrarevil.example.comsi el DNS no está bloqueado. Hay que usar prefijos específicos y restringir los conjuntos de caracteres. - Registrar orígenes rechazados: Registrar los orígenes que fallan la comprobación CORS ayuda a detectar URLs de preview mal configuradas y posibles intentos de ataque.
- Considerar un registro de previews: Para máxima seguridad, el pipeline CI/CD puede registrar cada URL de preview en KV o una base de datos, y validar contra ese registro en lugar de patrones regex. Esto añade latencia (lectura de KV) pero elimina riesgos de bypass de regex.
// Alternative: KV-based preview origin registry
// CI/CD pipeline writes: await KV.put(`preview-origin:${previewUrl}`, '1', { expirationTtl: 86400 * 7 })
async function isAllowedPreviewOrigin(
origin: string,
env: Env
): Promise<boolean> {
if (STATIC_ALLOWED_ORIGINS.has(origin)) return true;
const registered = await env.AUTH_KV.get(`preview-origin:${origin}`);
return registered !== null;
}
Actualización del middleware CSRF para entornos de preview:
El middleware CSRF (documentado anteriormente) también debe aceptar los orígenes dinámicos. Se actualiza allowedOrigins para usar la misma función isAllowedOrigin:
// workers/api-gateway/src/middleware/csrf.ts (updated)
export function csrfMiddleware(request: Request): Response | null {
const method = request.method.toUpperCase();
if (['GET', 'HEAD', 'OPTIONS'].includes(method)) {
return null;
}
const csrfHeader = request.headers.get('X-Requested-With');
if (csrfHeader !== 'XMLHttpRequest') {
return new Response(JSON.stringify({ error: 'CSRF validation failed' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
// Use the same origin validation as CORS
const origin = request.headers.get('Origin');
if (origin && !isAllowedOrigin(origin)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
return null;
}
Referencias
- WorkOS AuthKit Documentation -- Guías de configuración, referencia de API y patrones de integración para AuthKit.
- WorkOS AuthKit React SDK -- El paquete
@workos-inc/authkit-reactusado en la shell app. - jose Library (JWT/JWS/JWE for Edge Runtimes) -- Validación de JWT basada en Web Crypto API, usada en Cloudflare Workers.
- WorkOS JWKS Endpoint -- Endpoint de claves públicas para verificación de firmas JWT.
- WorkOS Organizations -- Documentación del modelo multi-tenancy.
- WorkOS Roles and Permissions -- Configuración RBAC para control de acceso granular.
- OWASP Session Management Cheat Sheet -- Mejores prácticas de la industria para gestión segura de sesiones.
- OWASP Authentication Cheat Sheet -- Guías de seguridad para implementaciones de autenticación.
- Cloudflare Workers Web Crypto API -- Primitivas criptográficas disponibles en el runtime de Workers.