All files / src/utils auth.ts

97.75% Statements 87/89
91.17% Branches 31/34
100% Functions 8/8
97.75% Lines 87/89

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177  1x           1x 1x     1x                   1x 1x   1x 1x                   1x 4x 4x 4x 4x 4x 4x 3x 2x   4x                     1x 2x 2x 2x 2x 2x   2x 2x 2x   2x 2x   2x 2x               1x 1x 1x 1x         1x 1x 1x 1x   1x 2x 2x 2x 2x 2x                 1x 3x 3x 3x 3x   3x 3x 3x 3x 2x   3x 3x               1x 5x 5x 5x   3x 3x 2x 1x   5x 5x 5x 5x 5x 5x 5x 5x   5x 5x   3x 5x     5x                   1x 4x 4x 4x 4x 4x     4x     2x 2x  
// Constants
import { ENV, COOKIE_KEYS } from '@/constants';
 
// Types
import { User } from 'firebase/auth';
 
// Utils
import { getCookieArray, setCookieArray, compareStringArrays } from '@/utils';
import { cookieStorage } from '@shared/utils';
 
// Types
import { PrivateRoute, UserProfile, UserRole } from '@/types/auth';
import { CookieAttributes, UserGroup } from '@shared/types';
 
/**
 * Constructs a full API endpoint URL by combining the base URL with a given path.
 * It ensures there are no duplicate slashes between the base and the path.
 *
 * @param path - The relative path to append to the base endpoint (e.g., 'rpc')
 * @returns The full URL (e.g., 'https://api.example.com/rpc')
 */
export const getEndpointUrl = (path: string = ''): string => {
  const base = ENV.API_ENDPOINT;
 
  return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
};
 
/**
 * Check whether the Firebase auth state has changed compared to stored shared values.
 *
 * @param nextUser - The next Firebase `User` object from onAuthStateChanged, or `null` if signed out.
 * @param tokenShared - The shared token stored in cookies/local storage (if any).
 * @param emailShared - The shared user email stored in cookies/local storage (if any).
 * @returns `true` if the auth state has changed and requires handling, otherwise `false`.
 */
export const hasUserStateChanged = (
  nextUser: User | null,
  tokenShared?: string | null,
  emailShared?: string | null,
): boolean => {
  return (
    (!!nextUser && !tokenShared) ||
    (!nextUser && !!tokenShared) ||
    (!!nextUser && !!emailShared && nextUser.email !== emailShared)
  );
};
 
type UserRoleData = { role: string; groups: string[] };
 
/**
 * Check and synchronize user role and groups with cookies.
 *
 * @param role   - The latest user role from backend.
 * @param groups - The latest user groups from backend (default is []).
 * @returns True if role or groups were changed and updated in cookies, otherwise false.
 */
export const checkSyncUserRole = ({
  role,
  groups = [],
}: UserRoleData): boolean => {
  const currentUserRole = cookieStorage.getItem(COOKIE_KEYS.USER_ROLE);
  const currentUserGroups = getCookieArray(COOKIE_KEYS.USER_GROUPS);
 
  const isChangedRole = !!role && !!currentUserRole && role !== currentUserRole;
  const isChangedGroups =
    !!currentUserRole && !compareStringArrays(currentUserGroups, groups);
 
  cookieStorage.setItem(COOKIE_KEYS.USER_ROLE, role);
  setCookieArray(COOKIE_KEYS.USER_GROUPS, groups);
 
  return isChangedRole || isChangedGroups;
};
 
/**
 * Clear authentication-related cookies
 *
 * @param normalKeys - List of cookie keys that can be removed without extra config
 * @param sharedKeys - List of cookie keys that require special cookie config
 */
export const clearAuthCookies = (
  normalKeys: string[],
  sharedKeys: string[],
  sharedConfig: {
    domain: string;
    sameSite: CookieAttributes['sameSite'];
    secure: true;
  },
) => {
  normalKeys.forEach((key) => cookieStorage.removeItem(key));
  sharedKeys.forEach((key) => cookieStorage.removeItem(key, sharedConfig));
};
 
export const findMatchedRoute = (
  pathname: string,
  routes: { path: string; role?: UserRole; groups?: UserGroup[] }[],
) => {
  return routes.find((r) => pathname.startsWith(r.path));
};
 
/**
 * Check if a user has access to a private route
 *
 * @param user - Current logged-in user
 * @param route - The private route to check
 * @returns boolean - true if user has permission, false otherwise
 */
export const checkPermission = (
  user: UserProfile,
  route: PrivateRoute,
): boolean => {
  if (!user) return false;
 
  const matchRole = !!route.role && user.role === route.role;
  const matchGroup =
    !!route.groups &&
    !!user.groups &&
    user.groups.some((g: UserGroup) => route.groups!.includes(g));
 
  return matchRole || matchGroup;
};
 
/**
 * Checks whether a JWT token is expired.
 *
 * @param token - The JWT token string to validate.
 * @returns {boolean} - Returns `true` if the token is expired or invalid, otherwise `false`.
 */
export const isTokenExpired = (token: string): boolean => {
  try {
    const [, payload] = token.split('.');
    if (!payload) return true;
 
    const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
    const decodedStr = globalThis.atob
      ? globalThis.atob(base64)
      : Buffer.from(base64, 'base64').toString('binary');
 
    const decoded = JSON.parse(
      decodeURIComponent(
        decodedStr
          .split('')
          .map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
          .join(''),
      ),
    );
 
    const exp = decoded?.exp;
    if (!exp) return true;
 
    return exp < Math.floor(Date.now() / 1000);
  } catch {
    return true;
  }
};
 
/**
 * Determines whether a user is valid based on their role and groups.
 *
 * @param role - The user's role (e.g. ADMIN, USER).
 * @param groups - A list of groups the user belongs to.
 * @param requiredGroups - One or more groups that are required for access.
 * @returns `true` if the user is an ADMIN or belongs to any of the required groups.
 */
export const isValidMember = (
  role?: UserRole,
  groups: UserGroup[] = [],
  requiredGroups: UserGroup[] = [],
): boolean => {
  if (!role) return false;
 
  // Admin always passes validation
  if (role === UserRole.ADMIN) return true;
 
  // Check if the user belongs to at least one required group
  return groups.some((g) => requiredGroups.includes(g));
};