MSAL Auth Handler

Status

Initial Rust implementation is complete in light-pingora and light-gateway. It includes config loading (msal-auth.yml), standalone Microsoft Entra ID token validation through security-msal.yml, double-submit cookie CSRF handling, gateway auth-principal propagation, and downstream Authorization injection.

Purpose

The msal-auth module is an alternative to msal-exchange for Microsoft Entra ID single-page application (SPA) architectures where the frontend acts as the primary OAuth client.

In this flow:

  1. The SPA handles Microsoft authentication, token acquisition, and token refresh directly.
  2. The SPA submits the Entra ID access token to the gateway's /auth/ms/login endpoint.
  3. The gateway validates the Entra ID token with security-msal.yml and sets the accessToken and csrf cookies using the double-submit cookie pattern.
  4. On subsequent API calls, the gateway validates the Microsoft JWT with expiry enforcement, compares the CSRF request value to the CSRF cookie, sets the gateway auth principal for later handlers, and forwards the token in the Authorization: Bearer header.

This eliminates the need for an internal light-oauth token exchange, reducing infrastructure dependencies while maintaining backend API security.

Configuration

handler.yml

Register msal-auth in the handler chain before handlers that need ctx.auth or the downstream Authorization header, such as access-control, router, or proxy handling.

handlers:
  - cors
  - msal-auth
  - router

chains:
  bff:
    - cors
    - msal-auth
    - router

paths:
  - path: /auth/ms/login
    method: POST
    exec:
      - bff
  - path: /auth/ms/login
    method: OPTIONS
    exec:
      - bff
  - path: /auth/ms/logout
    method: POST
    exec:
      - bff
  - path: /auth/ms/logout
    method: OPTIONS
    exec:
      - bff

defaultHandlers:
  - cors
  - msal-auth
  - router

msal-auth.yml

enabled: ${msal-auth.enabled:true}
loginPath: ${msal-auth.loginPath:/auth/ms/login}
logoutPath: ${msal-auth.logoutPath:/auth/ms/logout}
logoutCsrfEnforced: ${msal-auth.logoutCsrfEnforced:false}
cookieDomain: ${msal-auth.cookieDomain:localhost}
cookiePath: ${msal-auth.cookiePath:/}
cookieSecure: ${msal-auth.cookieSecure:false}
sessionTimeout: ${msal-auth.sessionTimeout:3600}
cookieSameSite: ${msal-auth.cookieSameSite:None}

security-msal.yml

msal-auth requires security-msal.yml when the handler is active and msal-auth.enabled is true. The config is loaded independently from the normal security.yml runtime.

enableVerifyJwt: ${security-msal.enableVerifyJwt:true}
ignoreJwtExpiry: ${security-msal.ignoreJwtExpiry:false}
enableRelaxedKeyValidation: ${security-msal.enableRelaxedKeyValidation:false}
issuer: ${security-msal.issuer:}
audience: ${security-msal.audience:}
jwt:
  clockSkewInSeconds: ${security-msal.jwt.clockSkewInSeconds:60}

Handlers

  • Login (/auth/ms/login): Expects an Entra ID token in the Authorization: Bearer header. Validates it using the security-msal runtime with expiry enforcement. Generates a secure CSRF token and returns both accessToken and csrf as Set-Cookie headers.
  • Logout (POST /auth/ms/logout): Validates logout CSRF when configured, clears every cookie the runtime sets (accessToken and csrf), and returns 204 No Content without a response body or content type.
  • Session Validation (any path with cookies): Reads the accessToken cookie. Validates the JWT with expiry enforcement. Checks that the CSRF request value matches the CSRF cookie. If valid, it sets the gateway auth principal and forwards the accessToken downstream in the Authorization: Bearer header.

Frontend Integration

The Single Page Application (SPA) must coordinate with the gateway for session creation and destruction.

Login Request

When the SPA acquires an access token from Microsoft Entra ID (e.g., using MSAL.js), it must send that token to the gateway's login endpoint to establish the secure HTTP-only cookies. Both login and logout use POST; neither needs a request body. A zero-length body is also accepted when a shared client sets Content-Type: application/json.

For cross-origin deployments, both examples require preflight: login sends the non-safelisted Authorization header and CSRF-protected logout sends the non-safelisted X-CSRF-TOKEN header. Keep the explicit OPTIONS routes and qualify the exact origin, credentials, method, and requested headers.

async function gatewayLogin(entraIdToken) {
  const response = await fetch('/auth/ms/login', {
    method: 'POST',
    credentials: 'include',
    headers: {
      'Authorization': `Bearer ${entraIdToken}`
    }
  });

  if (!response.ok) {
    throw new Error('Failed to create gateway session');
  }
  
  console.log('Gateway session established');
}

Logout Request

When the user logs out, the SPA must call the gateway's logout endpoint to clear the HTTP-only session cookies. Send credentials and the X-CSRF-TOKEN header read from the readable csrf cookie; no body is needed.

// Helper to read the csrf cookie
function getCookie(name) {
  const value = `; ${document.cookie}`;
  const parts = value.split(`; ${name}=`);
  if (parts.length === 2) return parts.pop().split(';').shift();
}

async function gatewayLogout() {
  const csrfToken = getCookie('csrf');
  
  const response = await fetch('/auth/ms/logout', {
    method: 'POST',
    credentials: 'include',
    headers: {
      'X-CSRF-TOKEN': csrfToken
    }
  });

  if (response.status !== 204) {
    throw new Error(`Unexpected logout status ${response.status}`);
  }

  console.log('Gateway session cleared');
}

Login and logout are POST-only. A legacy GET or any other unsupported method returns 405, ERR10008, and Allow: POST before authentication or cookie side effects. Keep explicit OPTIONS routes permanently with cors before msal-auth in the selected chain.

Error Handling

CodeMeaning
ERR10008Method is not allowed; MSAL login/logout responses advertise Allow: POST.
ERR10036Logout CSRF header is missing when enforcement is enabled.
ERR11649Logout CSRF cookie/header validation failed without exposing either value.

API Request

For standard API calls to backend services, the browser will automatically include the HTTP-only accessToken cookie. However, any request that modifies state or requires CSRF protection must include the CSRF token. The SPA must read the csrf cookie and append it as the X-CSRF-TOKEN header.

async function callBackendApi(endpoint, data) {
  const csrfToken = getCookie('csrf');
  
  const response = await fetch(endpoint, {
    method: 'POST', // or PUT, DELETE, etc.
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-TOKEN': csrfToken
    },
    body: JSON.stringify(data)
  });

  if (!response.ok) {
    throw new Error('API call failed');
  }

  return response.json();
}

WebSocket Connection

The browser's native WebSocket API does not allow setting custom HTTP headers. To pass the CSRF token during the WebSocket handshake upgrade, the SPA must pass it as a subprotocol string prefixed with csrf.. The gateway will extract and validate it.

function connectWebSocket(path) {
  const csrfToken = getCookie('csrf');
  
  // Create a subprotocol string that the gateway recognizes
  const csrfProtocol = `csrf.${csrfToken}`;
  
  // Note: Depending on your WebSocket server, you may also need to pass 
  // the actual subprotocol you intend to use (e.g., 'wamp', 'graphql-ws')
  // alongside the csrf protocol.
  const ws = new WebSocket(`wss://api.example.com${path}`, [csrfProtocol]);

  ws.onopen = () => {
    console.log('WebSocket connected securely');
  };

  ws.onerror = (error) => {
    console.error('WebSocket connection failed (possible CSRF or Auth issue)', error);
  };

  return ws;
}

Because an Entra ID token cannot be minted with a custom CSRF claim by this gateway, msal-auth enforces CSRF protections using the double-submit cookie pattern. The SPA reads the generated csrf cookie and submits it back.

The CSRF value is accepted from the following sources, in order of precedence:

  1. X-CSRF-TOKEN header.
  2. Sec-WebSocket-Protocol value starting with csrf. (when the request has Sec-WebSocket-Key and Sec-WebSocket-Version). This provides specialized CSRF support for Websocket upgrades since browser WebSockets cannot send custom HTTP headers.
  3. Query parameter csrf.

The gateway compares the value from one of these sources against the csrf cookie. If they match, the session is validated.

Refresh Flow

Unlike msal-exchange or stateless-auth, msal-auth does not issue or manage refresh tokens. The SPA is responsible for using MSAL.js to silently refresh the Entra ID token and calling /auth/ms/login again to update the session cookies before they expire.

Reload Behavior

The gateway reloads msal-auth when handler.yml, msal-auth.yml, or security-msal.yml changes. Reloading security-msal.yml refreshes both msal-auth and msal-exchange because both handlers validate Microsoft tokens with that security runtime.