Naalya Handbook
Authentication

Sessions & Tokens

Where access and refresh tokens live — the persisted Zustand store, request-time injection, login, logout, and impersonation.

A session in the Education Hub is nothing more than two strings — an access token and a refresh token — and the whole question of "is this person logged in?" comes down to where those strings live and how they reach the API. Get that mental model right and the rest of auth is plumbing.

Here's the shape of it. The tokens live in a small Zustand store that persists itself to localStorage. An Axios request interceptor reads them back out and stamps every outgoing request with an Authorization header. Logging in writes the tokens; logging out clears them; impersonation temporarily swaps them. That's the entire surface area.

One source of truth, two readers

The tokens live in exactly one place — the auth-token entry in localStorage. The Zustand store writes it; the Axios interceptor reads it. Nothing else owns the session. When something feels mysteriously logged-out, that key is the first thing to inspect.

The token store

The store is useAuth, a Zustand create wrapped in the persist middleware. It holds the two tokens plus two actions — setTokens and clearTokens — and nothing else. The magic is in the second argument to persist: it serializes the whole store to localStorage under the key auth-token, so the session survives a page reload.

src/components/providers/auth.provider.tsx
export const useAuth = create<AuthState>()(
  persist(
    (set) => ({
      token: null,
      refreshToken: null,
      setTokens: (token, refreshToken) => set({ token, refreshToken }),
      clearTokens: () => set({ token: null, refreshToken: null }),
    }),
    { name: tokenKey, storage: createJSONStorage(() => localStorage) },
  ),
)

The persisted shape is { state: { token, refreshToken }, version } — that nesting matters in a moment, because the interceptor reads localStorage directly rather than through the React hook.

Injecting the token

React components read tokens through useAuth, but Axios runs outside React — it can't call a hook. So the request interceptor reaches into localStorage itself via getStoredAuth(), pulls the access token out of that nested state object, and sets the Bearer header. Every request the generated client makes flows through here.

src/services/api/api.instance.ts
axiosInstance.interceptors.request.use(
  (config) => {
    const { token } = getStoredAuth()
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  (error) => Promise.reject(error),
)

Why localStorage, not the hook

getStoredAuth() parses the persisted auth-token entry and returns parsed.state.token. It reads storage directly because the interceptor isn't a component — there's no React render to subscribe to. The trade-off: it's reading the same key Zustand writes, so the two never disagree.

If a request comes back 401, a second interceptor quietly refreshes the token and replays the request — and carefully queues any other requests that fail mid-refresh so they don't all hammer /auth/refresh at once. That dance has its own page; see The Refresh Queue.

Logging in

There are two front doors, and both end at the same place — setStoredAuth (or setTokens), then a getMe() call to learn who you are, then a route to wherever that user belongs.

Email + password goes through useLogin, which is a thin wrapper over the generated login mutation hitting POST /api/v1/auth/login. The sign-in form takes the returned tokens, writes them, refetches the session, and hands off to getUserDestination to decide the landing route.

OAuth (Microsoft) comes back as a redirect to /auth/callback?accessToken=…&refreshToken=…. The route pulls those tokens straight off the search params and runs the identical tail — write, fetch, route:

src/routes/auth/callback.tsx
const { accessToken, refreshToken } = Route.useSearch()
// ...
setStoredAuth(accessToken, refreshToken)
queryClient.removeQueries({ queryKey: authQueryKeys.getMe })
const me = await queryClient.fetchQuery(AuthOptions.getMe())
navigate({ ...(me ? getUserDestination(me) : { to: '/' }), replace: true })

The callback runs twice — on purpose

The callback route does this work in both beforeLoad (SSR-safe, but only on the client where window exists) and in a useEffect. On the server localStorage doesn't exist, so beforeLoad bails early and the component finishes the job. Same logic, two homes — that's why you'll see it duplicated.

Logging out

useLogout is the mirror image, with one ordering subtlety. It first reads the current user from cache to decide where to send them — a school user returns to their school's public landing (/$reference), a platform_admin has no school so they fall back to /. Then it clears everything.

src/queries/auth/use-auth.tsx
try {
  await logoutMutation()
} finally {
  clearTokens()
  queryClient.clear()
  if (reference) navigate({ to: '/$reference', params: { reference } })
  else navigate({ to: '/' })
}

The finally is doing real work: even if the server-side logout call fails, the local session is still torn down. You never want a network blip to leave someone "logged out but not really." Note it clears the React Query cache too — tokens alone aren't the session; cached me data is part of it.

Impersonation

When an admin impersonates a user, they need the target's tokens active — but they must never lose their own. The solution is a separate Zustand store, useImpersonation, that does a stash-and-swap: start() saves the admin's real tokens as origin, then writes the target's tokens over the top. restore() puts the admin's tokens back.

src/components/providers/impersonation.provider.tsx
start: ({ accessToken, refreshToken, targetName }) => {
  if (get().isImpersonating) return // already swapped — don't stash a stash
  const current = getStoredAuth()
  if (current.token && current.refreshToken) {
    set({ origin: { token: current.token, refreshToken: current.refreshToken } })
  }
  setStoredAuth(accessToken, refreshToken)
  set({ isImpersonating: true, targetName })
},

This store persists under its own key, impersonation-origin — separate from auth-token — so the stashed admin session survives a reload mid-impersonation.

The re-entry guard is load-bearing

That if (get().isImpersonating) return is not a nicety. Without it, starting a second impersonation while one is active would stash the impersonation tokens as origin, permanently overwriting the admin's real session. You'd never get back. The guard forces you to exit first.

Exiting is deliberately forgiving. useExitImpersonation tries to revoke the session on the server, but restore() runs in a finally — so even if the un-impersonate call fails, the admin's tokens are swapped back locally and they're routed to their own hub. A failed network call must never trap an admin inside someone else's account.

Where to go next

On this page