Skip to main content

Core Authentication

MStore Dashboard menggunakan JWT authentication dengan pendekatan keamanan tinggi: access token disimpan di memory dan refresh token sebagai HttpOnly cookie.

Security Architecture

Token Storage Strategy

Token TypeStorageSecurity Reason
Access TokenMemory (Pinia store)Tidak accessible oleh XSS
Refresh TokenHttpOnly CookieTidak accessible oleh JavaScript
Access token tidak pernah disimpan di localStorage atau sessionStorage untuk mencegah serangan XSS.

Authentication Flow

Login Flow

Token Refresh Flow

Implementation

Auth Store

// features/01_core/store/auth.ts
import { defineStore } from 'pinia'
import { jwtDecode } from 'jwt-decode'
import type { User, JwtClaims } from '../types'

export const useAuthStore = defineStore('auth', () => {
  // State
  const accessToken = ref<string | null>(null)
  const user = ref<User | null>(null)
  const isAuthenticated = ref(false)
  const isLoading = ref(false)

  // Getters
  const claims = computed<JwtClaims | null>(() => {
    if (!accessToken.value) return null
    try {
      return jwtDecode<JwtClaims>(accessToken.value)
    } catch {
      return null
    }
  })

  const isTokenExpired = computed(() => {
    if (!claims.value?.exp) return true
    return Date.now() >= claims.value.exp * 1000
  })

  const hasRole = (role: string) => {
    return claims.value?.roles?.includes(role) ?? false
  }

  const hasPermission = (permission: string) => {
    return claims.value?.permissions?.includes(permission) ?? false
  }

  // Actions
  const setToken = (token: string) => {
    accessToken.value = token
    isAuthenticated.value = true
  }

  const setUser = (userData: User) => {
    user.value = userData
  }

  const logout = async () => {
    try {
      await $fetch('/api/v1/auth/logout', {
        method: 'POST',
        credentials: 'include',
      })
    } catch (e) {
      // Ignore logout errors
    } finally {
      accessToken.value = null
      user.value = null
      isAuthenticated.value = false
      navigateTo('/login')
    }
  }

  return {
    // State
    accessToken,
    user,
    isAuthenticated,
    isLoading,
    // Getters
    claims,
    isTokenExpired,
    hasRole,
    hasPermission,
    // Actions
    setToken,
    setUser,
    logout,
  }
}, {
  // Only persist user info, NOT token
  persist: {
    pick: ['user', 'isAuthenticated'],
  },
})

JWT Claims Interface

// features/01_core/types/auth.types.ts
export interface JwtClaims {
  sub: string          // User ID
  email: string        // User email
  name: string         // User name
  roles: string[]      // User roles (e.g., ['admin', 'cashier'])
  permissions: string[] // Permissions (e.g., ['inventory.read', 'inventory.write'])
  merchantId?: string  // Merchant ID (for multi-tenant)
  branchId?: string    // Branch ID
  iat: number          // Issued at
  exp: number          // Expiration time
}

export interface User {
  id: string
  email: string
  name: string
  avatar?: string
  roles: string[]
  merchantId?: string
  branchId?: string
}

export interface LoginRequest {
  email: string
  password: string
}

export interface LoginResponse {
  access_token: string
  user: User
}

Auth Composable

// features/01_core/composables/useAuth.ts
import { useAuthStore } from '../store/auth'
import type { LoginRequest } from '../types'

export const useAuth = () => {
  const store = useAuthStore()
  const router = useRouter()

  const login = async (credentials: LoginRequest) => {
    store.isLoading = true

    try {
      const response = await $fetch<LoginResponse>('/api/v1/auth/login', {
        method: 'POST',
        body: credentials,
        credentials: 'include', // Important: Include cookies
      })

      store.setToken(response.access_token)
      store.setUser(response.user)

      // Redirect to intended page or dashboard
      const redirectUrl = useRoute().query.redirect as string
      router.push(redirectUrl || '/')

      return response
    } catch (error) {
      throw error
    } finally {
      store.isLoading = false
    }
  }

  const logout = async () => {
    await store.logout()
  }

  const refreshToken = async () => {
    try {
      const response = await $fetch<{ access_token: string }>('/api/v1/auth/refresh-token', {
        method: 'POST',
        credentials: 'include',
      })

      store.setToken(response.access_token)
      return response.access_token
    } catch (error) {
      store.logout()
      throw error
    }
  }

  return {
    // State
    user: computed(() => store.user),
    isAuthenticated: computed(() => store.isAuthenticated),
    isLoading: computed(() => store.isLoading),
    // Methods
    login,
    logout,
    refreshToken,
    hasRole: store.hasRole,
    hasPermission: store.hasPermission,
  }
}

Auth Middleware

// middleware/auth.global.ts
export default defineNuxtRouteMiddleware(async (to) => {
  const authStore = useAuthStore()

  // Public routes that don't require auth
  const publicRoutes = ['/login', '/register', '/forgot-password']

  if (publicRoutes.includes(to.path)) {
    // If already authenticated, redirect to dashboard
    if (authStore.isAuthenticated) {
      return navigateTo('/')
    }
    return
  }

  // Protected routes - check authentication
  if (!authStore.isAuthenticated) {
    return navigateTo({
      path: '/login',
      query: { redirect: to.fullPath },
    })
  }

  // Check if token is expired and try to refresh
  if (authStore.isTokenExpired) {
    try {
      const { refreshToken } = useAuth()
      await refreshToken()
    } catch (error) {
      return navigateTo('/login')
    }
  }
})

Login Page Implementation

<!-- app/pages/login.vue -->
<script setup lang="ts">
import { useAuth } from '~/features/01_core'

definePageMeta({
  layout: 'auth',
})

const { login, isLoading } = useAuth()

const form = reactive({
  email: '',
  password: '',
})

const error = ref<string | null>(null)

const handleSubmit = async () => {
  error.value = null

  try {
    await login({
      email: form.email,
      password: form.password,
    })
  } catch (e) {
    if (e instanceof Error) {
      error.value = e.message
    } else {
      error.value = 'Login failed. Please try again.'
    }
  }
}
</script>

<template>
  <div class="min-h-screen flex items-center justify-center">
    <UCard class="w-full max-w-md">
      <template #header>
        <h1 class="text-2xl font-bold">Login</h1>
      </template>

      <form @submit.prevent="handleSubmit" class="space-y-4">
        <UAlert v-if="error" color="red" :title="error" />

        <UFormGroup label="Email">
          <UInput
            v-model="form.email"
            type="email"
            placeholder="Enter your email"
            required
          />
        </UFormGroup>

        <UFormGroup label="Password">
          <UInput
            v-model="form.password"
            type="password"
            placeholder="Enter your password"
            required
          />
        </UFormGroup>

        <UButton
          type="submit"
          color="primary"
          block
          :loading="isLoading"
        >
          Login
        </UButton>
      </form>
    </UCard>
  </div>
</template>

Role-Based Access Control

Check Role in Component

<script setup lang="ts">
const { hasRole, hasPermission } = useAuth()
</script>

<template>
  <div>
    <!-- Show only for admin -->
    <UButton v-if="hasRole('admin')">
      Admin Settings
    </UButton>

    <!-- Show only if has permission -->
    <UButton v-if="hasPermission('inventory.write')">
      Add Product
    </UButton>
  </div>
</template>

Route-Level Permission

// middleware/admin.ts
export default defineNuxtRouteMiddleware(() => {
  const { hasRole } = useAuth()

  if (!hasRole('admin')) {
    return navigateTo('/unauthorized')
  }
})
<!-- app/pages/admin/index.vue -->
<script setup lang="ts">
definePageMeta({
  middleware: ['admin'],
})
</script>

Security Best Practices

  • NEVER simpan access token di localStorage/sessionStorage
  • Access token hanya di memory (Pinia store)
  • Refresh token sebagai HttpOnly cookie
  • Clear tokens on logout
  • Semua komunikasi harus via HTTPS
  • Set Secure flag pada cookies
  • Use SameSite=Strict atau Lax
  • Access token: short-lived (15-30 minutes)
  • Refresh token: longer-lived (7-30 days)
  • Auto-refresh sebelum expiration
  • Force re-login jika refresh token expired
  • Sanitize user input
  • Use Vue’s built-in XSS protection
  • Content Security Policy (CSP) headers
  • Avoid v-html with user content

Next Steps