Skip to content

Last updated:

Authentication

CPR uses a Bearer token authentication system backed by a Laravel API. The frontend handles login, token storage, auth state hydration, route protection, password reset, logout, and branch switching.

Login Flow

Step 1: User Submits Credentials

The user enters a username and password on the /login page. The AuthService.login() method sends a POST request to the API:

ts
// From auth.sevice.ts
async login(username: string, password: string): Promise<LoginResponse> {
  try {
    const config = useRuntimeConfig();
    const base = (config.public?.API_URL as string) || '';
    const url = `${base}/auth/login`;

    const res: ApiResponse<LoginResponse> = await $fetch(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json',
      },
      body: JSON.stringify({ username, password }),
    });

    return res.data;
  } catch (err: unknown) {
    const fetchError = err as FetchError;
    const message =
      fetchError.data?.message ?? fetchError.message ?? 'Something went wrong';
    Sentry.captureException(err);
    throw new Error(message);
  }
}

Step 2: Token Stored in sessionStorage

On successful login, the response contains a user object and a token. These are persisted to sessionStorage:

ts
// Login page stores auth state
const response = await authService.login(username, password);

// Store in sessionStorage for persistence across page refreshes
sessionStorage.setItem('auth', JSON.stringify({
  user: response.user,
  token: response.token,
}));

Step 3: Auth Store Populated

The Pinia auth store holds the active session state in memory:

ts
// From auth.store.ts
interface AuthState {
  user: AuthUser | null;
  token: string | null;
  resetCodeVerified?: boolean;
  branchServices: { id: number; name: string; code: string }[] | null;
}

export const useAuthStore = defineStore('auth', () => {
  const state = reactive<AuthState>({
    user: null,
    token: null,
    resetCodeVerified: false,
    branchServices: null,
  });

  const isAuthenticated = computed(() => !!state.token);

  // ... hydrate, reset, updateBranch
});

Auth State Hydration

When the page refreshes, the Pinia store is empty. The hydrate() method restores state from sessionStorage:

ts
// From auth.store.ts
const hydrate = () => {
  const storedAuth = sessionStorage.getItem('auth');

  if (storedAuth) {
    try {
      const storedStated: AuthState = JSON.parse(storedAuth);
      state.user = storedStated.user;
      state.token = storedStated.token;
      state.resetCodeVerified = storedStated.resetCodeVerified;
      state.branchServices = storedStated.user?.branch_services || [];
      authBranchStore.selectedBranch = state.user?.default_branch || null;
    } catch (e) {
      state.user = null;
      state.token = null;
      state.resetCodeVerified = false;
      console.warn('Failed to parse auth from sessionStorage:', e);
    }
  }
};

hydrate() is called in two places:

  1. Auth middleware -- On every route navigation.
  2. useApiFetch -- Before making API requests if the token is not in memory.

Auth Middleware

The global auth middleware (app/middleware/auth.ts) runs on every route navigation to enforce authentication:

ts
// From middleware/auth.ts
const isTokenExpired = (token: string): boolean => {
  try {
    const parts = token.split('.');
    // Only check expiry for JWT tokens (3 parts with base64 payload)
    if (parts.length !== 3) return false;
    const payload = JSON.parse(atob(parts[1] as string));
    if (!payload.exp) return false;
    return Date.now() >= payload.exp * 1000;
  } catch {
    // Non-JWT tokens (e.g., Sanctum) -- assume valid, let server handle expiry
    return false;
  }
};

export default defineNuxtRouteMiddleware((to) => {
  const authStore = useAuthStore();
  const publicPages = ['/login'];
  const publicPrefixes = ['/reset-password'];

  authStore.hydrate();

  const isPublic =
    publicPages.includes(to.path) ||
    publicPrefixes.some((prefix) => to.path.startsWith(prefix));

  if (!isPublic) {
    if (!authStore.state.token || isTokenExpired(authStore.state.token)) {
      authStore.reset();
      sessionStorage.removeItem('auth');
      return navigateTo('/login');
    }
  }
});

How the Middleware Works

  1. Hydrates auth state from sessionStorage on every navigation.
  2. Checks if the route is public -- /login and all /reset-password/* routes are accessible without authentication.
  3. For protected routes, checks whether a token exists and whether it has expired.
  4. JWT expiry check -- Decodes the JWT payload and compares the exp claim with the current time. If the token is not a JWT (Sanctum plain token), it skips client-side expiry and lets the server handle it.
  5. If expired or missing -- Resets the auth store, clears sessionStorage, and redirects to /login.

Public Route Configuration

ts
const publicPages = ['/login'];
const publicPrefixes = ['/reset-password'];

To add a new public route, add it to publicPages (exact match) or publicPrefixes (prefix match).

API Request Authentication

Every API request made through useApiFetch automatically includes the Bearer token:

ts
// From useApiFetch.ts
export const useApiFetch = async <T>(
  endpoint: string,
  options: ApiFetchOptions = {}
): Promise<T> => {
  const authStore = useAuthStore();

  if (!authStore.state.token) {
    authStore.hydrate();
  }

  const headers: Record<string, string> = {
    Accept: 'application/json',
    ...(options.headers as Record<string, string>),
  };

  if (authStore.state.token) {
    headers['Authorization'] = `Bearer ${authStore.state.token}`;
  }

  return $fetch<T>(url, { ...options, headers });
};

Logout Flow

Logout performs three actions:

  1. API call -- Notifies the server to invalidate the token (POST /auth/logout).
  2. Store reset -- Clears the Pinia auth store.
  3. sessionStorage clear -- Removes persisted auth data.
ts
// From auth.sevice.ts
async logout(): Promise<{ message: string }> {
  const authStore = useAuthStore();

  if (!authStore.state.token) {
    authStore.hydrate();
  }

  const res = await $fetch(url, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${authStore.state.token}`,
    },
  });

  return res.data;
}

After the API call, the login page typically runs:

ts
authStore.reset();
sessionStorage.removeItem('auth');
navigateTo('/login');

Password Reset Flow

The password reset flow is a multi-step process protected by the reset-password middleware.

Step 1: Send Reset Code

The user enters their email on /reset-password. The useSendCode composable sends the request:

ts
// From useSendCode.ts
const sendPasswordResetCode = async (email: string) => {
  if (!email) {
    error.value = 'Please enter your email address';
    return;
  }

  loading.value = true;
  try {
    await authService.sendResetCode(email);
    success.value = true;
  } catch (err: unknown) {
    error.value = errorObj.message || 'Failed to send code';
  } finally {
    loading.value = false;
  }
};

Step 2: Verify Code

On /reset-password/enter-code, the user enters the code received via email:

ts
// From useCodeVerification.ts
const verifyCode = async (email: string, code: string) => {
  loading.value = true;
  try {
    await authService.verifyCode(email, code);
    success.value = true;
  } catch (err: unknown) {
    error.value = errorObj.message || 'Failed to verify code';
  } finally {
    loading.value = false;
  }
};

Step 3: Set New Password

On /reset-password/new-password, the user enters a new password and confirmation:

ts
// API call
await authService.resetPassword(email, code, password, password_confirmation);

Step 4: Success

On /reset-password/success-state, a confirmation message is displayed.

Reset Password Middleware

The reset-password middleware enforces sequential step progression:

ts
// From middleware/reset-password.ts
export default defineNuxtRouteMiddleware((to) => {
  const store = useResetPasswordCodeStore();

  const stepRoutes: Record<string, number> = {
    '/reset-password': 1,
    '/reset-password/enter-code': 2,
    '/reset-password/new-password': 3,
    '/reset-password/success-state': 4,
  };

  const requiredStep = stepRoutes[to.path];
  if (!requiredStep) return;

  // Step 1 is always accessible
  if (requiredStep === 1) return;

  // Redirect to current step if user tries to skip ahead
  if (store.step < requiredStep) {
    return navigateTo(routeForStep[store.step]);
  }
});

The useResetPasswordCodeStore tracks progress through the flow:

ts
// From resetPassword.store.ts
export const useResetPasswordCodeStore = defineStore('resetPasswordCode', () => {
  const step = ref(1);
  const form = reactive<Form>({
    email: '',
    code: '',
    newPassword: '',
    confirmPassword: '',
  });

  const maskedEmail = computed(() => {
    const email = form.email;
    if (!email) return '';
    const [local, domain] = email.split('@');
    return `${local?.charAt(0)}***@${domain}`;
  });

  const resetStore = () => {
    step.value = 1;
    form.email = '';
    form.code = '';
    form.newPassword = '';
    form.confirmPassword = '';
  };

  return { step, form, maskedEmail, resetStore };
});

Branch Switching

CPR supports multi-branch operations. Users can switch between clinic branches without re-authenticating:

ts
// From useBranchSwitcher.ts
const switchBranch = async (branchId: string) => {
  loading.value = true;
  try {
    const branch = await authService.switchBranch(branchId);
    branchStore.selectedBranch = branch;
    success.value = true;
  } catch (err) {
    error.value = message;
  } finally {
    loading.value = false;
  }
};

The selected branch is stored in the useUserBranchStore and also updated in sessionStorage via authStore.updateBranch():

ts
// From auth.store.ts
const updateBranch = (branch: Branch) => {
  const storedAuth = sessionStorage.getItem('auth');
  if (storedAuth) {
    const storedStated: AuthState = JSON.parse(storedAuth);
    if (storedStated.user?.default_branch) {
      storedStated.user.default_branch = branch;
      sessionStorage.setItem('auth', JSON.stringify(storedStated));
    }
  }
};

Security Considerations

  1. JWT expiry is checked client-side but the server is the authority. Even if a token passes the client-side check, the server may reject it.
  2. Non-JWT tokens (Sanctum) skip client-side expiry checks. The catch block in isTokenExpired returns false, deferring to server-side validation.
  3. Password reset flow cannot be skipped -- The middleware enforces sequential steps. A user cannot navigate directly to /reset-password/new-password without completing prior steps.
  4. Sentry captures auth errors -- Failed logins and API errors are reported to Sentry for monitoring.

CPR - Clinical Patient Records