Autenticación

Tabla de contenidos


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 entornoValor de ejemploPropósito
RSBUILD_PUBLIC_WORKOS_CLIENT_IDclient_01H...Client ID del proyecto en WorkOS
RSBUILD_PUBLIC_AUTH_REDIRECT_URIhttps://app.example.com/auth/callbackURL 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étodoTipoDescripción
userUser | nullEl objeto del usuario autenticado, o null si no está autenticado
isLoadingbooleantrue 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()() => voidRedirige 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

Flujo de autenticación: navegador a AuthKit hosted UI a callback a JWT a API Gateway Navegador visita usuario AuthKit UI login alojado BFF Worker intercambio de código JWT cookie HttpOnly API Gateway valida JWT redirección callback tokens cookie El navegador nunca maneja tokens en crudo -- todo el intercambio ocurre del lado del servidor en el BFF Worker

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:

  1. 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_secret nunca sale del Worker.
  2. 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.
  3. 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.com incluye la cookie sin que el código del cliente necesite gestionar headers.

Validación de JWT en el API Gateway

Validación de JWT en el edge: petición a extraer cookie HttpOnly a verificación jose con JWKS desde caché KV Petición con cookie Extraer cookie sesión HttpOnly jose verify jwtVerify() Caché JWKS KV (5 min TTL) claves Petición autenticada reenviada a los servicios

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:

  • createRemoteJWKSet usa fetch nativo en lugar de node:http para 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 de KeyObject de Node.js. Este es un breaking change respecto a v4 -- cualquier código que haga type-check de KeyObject debe actualizarse.
  • importJWK devuelve CryptoKey directamente, eliminando la necesidad de casts as KeyLike en 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:

EscenarioLatencia
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 claves150-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

Gestión de sesiones: sesión BFF a cookie HttpOnly a flujo de renovación de tokens Navegador envía cookie auto. cookie HttpOnly BFF Worker descifrar sesión Token expirando? verificar expiresAt válido Reenviar petición expirado Refresh Token vía WorkOS API nueva cookie WorkOS token endpoint solo server-side

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:

AtributoValorRazó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
SameSiteStrictLa cookie no se envía en peticiones cross-site, mitigando CSRF
Domain.example.comCompartida entre todos los subdominios
Path/Disponible en todas las rutas
Max-Age2592000 (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

Multi-tenancy: el usuario pertenece a múltiples organizaciones, cambia de organización, el contexto se refresca Usuario autenticado Org A (admin) Org B (member) Org C (viewer) Selector de org seleccionar nueva org Refresco de contexto Nuevo JWT con org_id Nueva cookie de sesión MFEs recargan datos El claim org_id del JWT impone el aislamiento de datos en la capa de API

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 admin en una organización y member en 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):

PermisoDescripción
org:adminAcceso completo de administración de la organización
team:readVer miembros del equipo
team:inviteInvitar nuevos miembros al equipo
team:manageGestionar roles y eliminar miembros
team:deleteEliminar miembros del equipo
billing:readVer información de facturación
billing:manageActualizar métodos de pago y planes
projects:readVer proyectos
projects:writeCrear y editar proyectos
projects:deleteEliminar proyectos
settings:readVer ajustes de la organización
settings:writeModificar 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 HttpOnly cifradas (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éTTLJustificación
En memoria (caché integrada de createRemoteJWKSet)10 minutosRuta rápida; evita lecturas a KV para Workers activos
Cloudflare KV5 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íaAcción
Día 0Añ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 31Eliminar 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: true es incompatible con Access-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 registrar evil.example.com si 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