Wiring PalmyraStoreFactory for session-based auth#

The default PalmyraStoreFactory assumes Basic auth via an Authorization header. When your backend uses HTTP-session login (POST /auth/loginJSESSIONID cookie) with CSRF protection, three settings in the axios customizer make it work. This page is the recipe.

The full factory setup#

import { IEndPoint, PalmyraStoreFactory } from '@palmyralabs/palmyra-wire';
import { toast } from 'react-toastify';

export const ACL_ACCESS_KEY = 'aclAccess';

const errorHandler = () => (error: any) => {
  const s = error?.response?.status;
  if (s === 401) {
    localStorage.removeItem(ACL_ACCESS_KEY);
    window.location.assign('/app/login');
    return true;
  }
  if (s === 403) { toast.error("You don't have permission."); return true; }
  if (s >= 500)  { toast.error('Server error.');              return true; }
  return false;
};

const AppStoreFactory = new PalmyraStoreFactory({
  baseUrl: '/api/palmyra',
  errorHandlerFactory: errorHandler,
  storeOptions: {
    axiosCustomizer: (ax) => {
      ax.defaults.withCredentials = true;
      // CSRF defaults match Spring Security:
      //   ax.defaults.xsrfCookieName = 'XSRF-TOKEN';  // (axios default)
      //   ax.defaults.xsrfHeaderName = 'X-XSRF-TOKEN'; // (axios default)
    },
  },
});

export const useFormstore = (ep: IEndPoint, opts = {}, idKey?: string) =>
  AppStoreFactory.getFormStore(opts, ep, idKey);
export const useGridstore = (ep: IEndPoint, opts = {}, idKey?: string) =>
  AppStoreFactory.getGridStore(opts, ep, idKey);
export const useTreestore = (ep: IEndPoint, opts = {}) =>
  AppStoreFactory.getTreeStore(opts, ep);

export default AppStoreFactory;

Three behaviours this unlocks:

  1. withCredentials: true — the browser attaches JSESSIONID (and other cookies) to every request. Non-negotiable; without this, requests go out cookie-less and the server sees no session.
  2. Axios’s built-in CSRF handling — axios defaults read the XSRF-TOKEN cookie and echo it on every mutating request as X-XSRF-TOKEN. Those names match Spring Security’s CookieCsrfTokenRepository.withHttpOnlyFalse() defaults exactly — no additional config.
  3. 401 → redirect — expired session triggers errorHandler, which clears transient state and redirects to /app/login. The user logs in, the new session cookie overwrites the old, the SPA continues.

What you don’t do anymore#

  • Don’t attach an Authorization header. The session cookie is the credential.
  • Don’t cache the logged-in user in localStorage. Fetch /user/about on Topbar mount instead — the server is the source of truth. See the Security Configuration Recipes for the backend /user/about handler.
  • Don’t store the CSRF token yourself. Axios reads the cookie and writes the header for you.

Login flow on the client#

// pages/login/LoginPage.tsx
async function submit(e: FormEvent) {
  e.preventDefault();
  try {
    await axios.post(
      '/api/auth/login',
      { userName: user, password },
      { withCredentials: true },
    );
    // Session cookie now set; any subsequent palmyra-wire call authenticates via it.
    nav('/');
  } catch (x: any) {
    setErr(x?.response?.status === 401 ? 'Invalid credentials' : 'Login failed');
  }
}

Note the explicit withCredentials: true on the login call — it’s not going through AppStoreFactory, so axios’s default (false) applies unless overridden.

Logout#

async function signOut() {
  try { await axios.post('/api/auth/logout', null, { withCredentials: true }); }
  catch { /* best-effort — still clear local state and redirect */ }
  localStorage.removeItem(ACL_ACCESS_KEY);
  nav('/login');
}

Open devtools → Network → pick a POST:

  • Request Headers should show Cookie: JSESSIONID=…; XSRF-TOKEN=… and X-XSRF-TOKEN: <same token>.
  • Response Headers on the login call should show Set-Cookie: JSESSIONID=…; Path=/api; HttpOnly; SameSite=Lax.
  • First GET to any authenticated endpoint after login should trigger Set-Cookie: XSRF-TOKEN=<uuid>; Path=/api — if the cookie never lands, see Security Configuration Recipes § 3 (eager token resolution).

Dev vs prod#

In dev (Vite proxy at localhost:3000 → backend localhost:6060), SameSite=Lax + Secure=false lets the cookie flow across the proxy. In prod behind HTTPS:

server:
  servlet:
    session:
      cookie:
        secure:    true
        same-site: lax   # or strict if you don't need cross-site GETs

No axios change on the frontend — browsers handle the attribute bump transparently.

See also#