Skip to content

Auth and permissions ​

Sessions are HTTP-only cookies, permissions are a flat list, and no token is ever held in JavaScript.

Open in demo

The flow ​

  1. main.ts calls bootstrapAuth() before installing the router, because the router's first navigation runs synchronously and the guard reads the auth store.
  2. bootstrapAuth asks /auth/me with _skipAuthRefresh. A 401 there is the normal answer for a signed-out visitor, not an expired session, so it must not trigger a refresh and a hard redirect — that would reload the page and re-run bootstrap forever.
  3. On success it fills the store and rebuilds the CASL ability from the permission list.
  4. The guard then lets the first navigation through, or sends it to the sign-in page.

The store ​

ts
import { useAuthStore } from '@modules/auth/stores/auth.store'

const { user, permissions, isAuthenticated } = storeToRefs(useAuthStore())

setUser, setPermissions and $reset are its actions. It holds no token, because there is none to hold.

useAuth ​

ts
import { useAuth } from '@modules/auth/composables/useAuth'

const { login, logout, refreshSession } = useAuth()
MemberDescription
login(credentials)Signs in, rebuilds the ability, pushes to ?redirect= or the dashboard, and raises a welcome toast.
logout()Clears the session and the store and returns to the sign-in page.
refreshSession()Asks /auth/refresh and returns whether it worked.

bootstrapAuth is a standalone export rather than part of this composable: it runs from main.ts where there is no setup context and no router yet.

Your backend sets an HTTP-only session cookie. The shared axios instance sends withCredentials: true on every request, so the browser attaches and receives it.

For CSRF, the standard double-submit pattern: set an XSRF-TOKEN cookie and the request interceptor sends its value back as an X-CSRF-TOKEN header.

The refresh queue ​

When a request comes back 401, the response interceptor does four things:

  1. Allows exactly one refresh in flight at a time.
  2. Parks every other 401 that lands meanwhile in a queue.
  3. On success, replays the original request and then drains the queue, each waiter replaying its own.
  4. On failure, rejects the queue and sends the browser to /auth/login.

A request is retried at most once, and /auth/refresh is never itself retried: a failing refresh should fall through to the sign-in redirect, not start another refresh.

The pages ​

RouteNameLayoutWhat it does
/auth/loginloginauthEmail and password, with a show and hide toggle, remember me and a forgot link.
/auth/registerregisterauthCreate an account.
/auth/forgot-passwordforgot-passwordauthAsk for a reset email.
/auth/reset-passwordreset-passwordauthSet a new password from a token. Deliberately does not sign the reader in; it returns them to sign in.
/auth/locklock-screenauthRe-authenticate an existing session with a password.
/auth/two-factortwo-factorauthExchange an emailed or texted code for a session.
/forbiddenforbiddenblankWhere the guard sends a permission miss.

All seven are public: true, so a signed-out visitor can reach them.

features.humanVerification adds a verification step to sign in and sign up. It ships off, so the demo stays two clicks.

Four more error pages live beside them: /500, /maintenance, /coming-soon and /recover, plus the router's own /404 and /offline.

The mock ​

ini
VITE_USE_MOCK_AUTH=true

The shipped .env.development sets it, so npm run dev signs in with no server. Only the exact string true attaches it. When it is on, a request interceptor gives /auth/* its own adapter and the call never reaches the network:

PathMock behavior
/auth/login, /auth/registerAccept anything and return the Demo User.
/auth/unlock, /auth/two-factorThe same.
/auth/me, /auth/refreshThe Demo User while signed in, 401 otherwise.
/auth/logoutSigns out.
/auth/forgot-password, /auth/reset-passwordAccepted. Reset does not sign the reader in.

The Demo User is Demo User, demo@example.com, holding { action: 'manage', subject: 'all' }. "Signed in" is a flag in localStorage under sa-mock-auth-session, which is what makes the mock survive a reload.

What it does not do: set a cookie, validate a password, or expire a session. It exists so the pages and their states work before a backend does. A path you add must stay lowercase letters and hyphens, or it slips past the interceptor's /auth/[a-z-]+ match and hits the network.

window.__mockAuth401Once() in the console arms a single 401 on the next /auth/me, so the refresh path can be walked through by hand.

Permissions ​

/auth/me returns a flat list, and updateAbility turns it into the CASL ability:

json
{ "action": "read", "subject": "Invoice" }

An entry may carry conditions, which CASL matches against the object passed to can. The action manage means every action and the subject all means every subject.

The ability lives in @shared/lib/ability, not in the auth module, because every module needs can() and importing auth for it would break the rule that modules do not import modules.

Three things read it: the route guard through meta.permission, the sidebar through NavItem.permission, and the v-permission directive. The task view, with examples, is Permissions.

The guard ​

ts
export const authGuard: NavigationGuardWithThis<undefined> = (to) => {
  const auth = useAuthStore()

  if (to.meta.public)
    return true

  if (!auth.isAuthenticated)
    return { name: 'login', query: { redirect: to.fullPath } }

  if (to.meta.permission) {
    const [action = '', subject = ''] = to.meta.permission.split(' ')
    if (!ability.can(action, subject))
      return { name: 'forbidden' }
  }

  return true
}

A module page is never public. The redirect carries the original path, so signing in lands the reader where they were going.

Going to a real backend ​

Turn the mock off, point /api at your server, and answer the nine auth endpoints. Nothing else changes. The endpoint table and the 422 shape are on Connect your API.

Where to go next ​