Technical DocumentationProgressive Web AppAuth System

Authentication System Documentation

Overview

LandlordX uses a stateless JWT-based authentication system with refresh token rotation for secure, scalable user sessions. The frontend manages authentication state using Zustand for client state, TanStack Query for server state, and localStorage for persistent token storage.

Architecture

Four-Layer Design

The authentication system follows a clear separation of concerns:

┌─────────────────────┐
│   UI Components     │  (React components, forms, pages)
│  (auth/login, etc)  │
└──────────┬──────────┘

┌──────────▼──────────┐
│   React Query       │  (TanStack Query hooks)
│   (useAuth.ts)      │  • useVerifyOTP()
└──────────┬──────────┘  • useRefreshToken()
           │              • useLogout()
┌──────────▼──────────┐
│   Auth Store        │  (Zustand global state)
│  (auth.store.ts)    │  • User state
└──────────┬──────────┘  • isAuthenticated flag

┌──────────▼──────────┐
│  Auth Service       │  (API communication)
│ (auth.service.ts)   │  • verifyOTP()
└──────────┬──────────┘  • refreshAccessToken()
           │              • logout()
┌──────────▼──────────┐
│  Token Management   │  (localStorage utilities)
│   (tokens.ts)       │  • getRefreshToken()
└─────────────────────┘  • setAccessToken()
                         • clearTokens()

Key Files

FilePurposeResponsibilities
src/lib/auth/tokens.tsToken storage utilitieslocalStorage CRUD operations, SSR-safe checks
src/lib/api/auth.service.tsAPI service layerHTTP calls to auth endpoints (currently mocked)
src/hooks/useAuth.tsReact Query hooksTanStack Query mutations for auth operations
src/hooks/useLogoutWithNavigation.tsLogout with navigationCombines logout mutation with router navigation
src/lib/store/auth.store.tsGlobal auth stateZustand store with user, isAuthenticated flags
src/components/guards/auth-guard.tsxAuth route guardPrevents authenticated users from accessing auth routes
src/components/providers/query-provider.tsxQuery client providerWraps app with QueryClientProvider
src/components/providers/session-provider.tsxSession restoration wrapperUses useRefreshToken() hook on app mount
src/app/layout.tsxRoot layoutWraps app with QueryProvider and SessionProvider
src/app/auth/layout.tsxAuth layoutWraps auth routes with AuthGuard
src/app/(protected)/layout.tsxProtected layoutEnforces authentication for protected routes

Token Management

Token Types

  1. Access Token: Short-lived JWT (e.g., 15 minutes) for API authorization

    • Stored in localStorage under landlordx_access_token
    • Sent in Authorization: Bearer <token> header with API requests
    • Automatically refreshed when expired
  2. Refresh Token: Long-lived token (e.g., 7 days) for obtaining new access tokens

    • Stored in localStorage under landlordx_refresh_token
    • Used only for calling /api/auth/refresh endpoint
    • Rotated on each refresh (new refresh token returned)

Token Storage API

Located in src/lib/auth/tokens.ts:

// Store tokens
setAccessToken(token: string): void
setRefreshToken(token: string): void
 
// Retrieve tokens
getAccessToken(): string | null
getRefreshToken(): string | null
 
// Clear all tokens (logout)
clearTokens(): void
 
// Check authentication status
isAuthenticated(): boolean  // Returns true if refresh token exists

SSR Safety: All functions include typeof window !== "undefined" checks to prevent server-side errors.

Authentication Flow

1. Progressive Authentication Flow

LandlordX implements a 4-step progressive onboarding flow that automatically routes users based on their completion status:

Step 1: Login → Step 2: Verify OTP → Step 3: Create Profile → Step 4: DigiLocker → Dashboard

Automatic Step Progression:

  • After OTP verification, user state includes hasCompletedProfile and hasVerifiedProfile flags
  • AuthGuard checks these flags and automatically routes to the next incomplete step
  • Users cannot skip steps or access routes they haven’t reached yet

Flow Diagram:

User enters phone → /auth/login

         Redirect to /auth/verify

   User enters OTP → POST /api/auth/verify

   Returns user with completion flags

   ┌────────────────────────────┐
   │ AuthGuard checks user state│
   └────────────────────────────┘

         hasCompletedProfile?

       NO ───────┼───────→ /auth/create-profile
                 │              ↓
                 │         User fills profile
                 │              ↓
                 │         hasCompletedProfile = true
                 │              ↓
       YES ──────┘         hasVerifiedProfile?

                      NO ───────┼───────→ /auth/digilocker
                                │              ↓
                                │         User connects DigiLocker
                                │              ↓
                                │         hasVerifiedProfile = true
                                │              ↓
                      YES ──────┘              │

                                          /dashboard

See PROGRESSIVE_AUTH_FLOW.md for complete documentation.

Implementation:

  • Login page (src/app/auth/login/page.tsx): Phone number input with Zod validation (10 digits, starts with 6-9)
  • Verify page (src/app/auth/verify/page.tsx): Uses useVerifyOTP() hook, sets user state, relies on AuthGuard for routing
  • Create Profile page (src/app/auth/create-profile/page.tsx): Updates user with name/email, sets hasCompletedProfile = true
  • DigiLocker page (src/app/auth/digilocker/page.tsx): Connects DigiLocker or skips, sets hasVerifiedProfile = true
  • Auth Flow Utilities (src/lib/auth/auth-flow.ts): getNextAuthStep() and canAccessAuthStep() logic

2. Session Restoration Flow

On app mount or page reload:

App loads → QueryProvider + SessionProvider mount

         useEffect calls useRefreshToken() hook

     Check if isAuthenticated() (refresh token exists)

    YES → Call mutateAsync from useRefreshToken()

          Backend validates refresh token

          Returns { accessToken, refreshToken (rotated) }

          Store new tokens → setAccessToken(), setRefreshToken()

          Update auth state → isAuthenticated = true

    NO → clearUser() → Reset auth state

Implementation:

  • QueryProvider (src/components/providers/query-provider.tsx): Wraps entire app with QueryClientProvider
  • SessionProvider (src/components/providers/session-provider.tsx): Uses useRefreshToken() hook to restore session on mount
  • Root Layout (src/app/layout.tsx): Includes <QueryProvider><SessionProvider>{children}</SessionProvider></QueryProvider>

3. Token Refresh Flow

When access token expires during API call:

API request fails with 401 Unauthorized

   Intercept error in Axios interceptor (TODO)

   Call mutateAsync from useRefreshToken()

   Retry original request with new access token

Current Status: Mock refresh logic exists in auth.service.ts. Axios interceptor needs implementation.

4. Logout Flow

User clicks Logout → useLogoutWithNavigation()

          Call mutateAsync from useLogout()

          Call logout() service (optional API call)

              clearUser() → Calls clearTokens() internally

          Router navigates to /auth/login

Implementation:

  • useLogoutWithNavigation (src/hooks/useLogoutWithNavigation.ts): Combines TanStack Query mutation with navigation

React Query Hooks

Located in src/hooks/useAuth.ts:

Available Hooks

// OTP Verification
const { mutateAsync: verifyOTP, isPending } = useVerifyOTP();
const response = await verifyOTP({ phone: "+919876543210", otp: "123456" });
 
// Token Refresh
const { mutateAsync: refreshToken, isPending } = useRefreshToken();
const response = await refreshToken();
 
// Logout
const { mutateAsync: logout, isPending } = useLogout();
await logout();

Why TanStack Query?

All API calls MUST go through TanStack Query to maintain consistency and leverage its powerful features:

  • Automatic retries: Failed requests are retried once by default
  • Loading states: isPending flag for UI feedback
  • Error handling: Structured error responses
  • Caching: Prevents duplicate requests (configurable)
  • Optimistic updates: Update UI before API response (future feature)

Zustand Auth Store

Located in src/lib/store/auth.store.ts:

Purpose: Manages client-side authentication state only. Does NOT handle API calls (delegated to React Query hooks).

State Shape

interface AuthState {
  user: User | null; // Current authenticated user
  isAuthenticated: boolean; // Auth status flag
  isLoading: boolean; // Loading state
  setUser: (user: User) => void; // Set user after login
  clearUser: () => void; // Clear user and tokens
  setLoading: (loading: boolean) => void; // Set loading state
}

Usage in Components

// Get user data
const user = useAuthStore((state) => state.user);
 
// Check auth status
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
 
// Update user after login (used with React Query mutation)
const setUser = useAuthStore((state) => state.setUser);
const { mutateAsync: verifyOTP } = useVerifyOTP();
const response = await verifyOTP({ phone, otp });
setUser(response.user);
 
// Clear user and tokens
const clearUser = useAuthStore((state) => state.clearUser);
clearUser(); // Also calls clearTokens() internally
 
// Logout with navigation
const { handleLogout, isPending } = useLogoutWithNavigation();
await handleLogout(); // Calls logout API, clears state, navigates to /auth/login

Mock API Implementation

All API calls in src/lib/api/auth.service.ts are currently mocked with simulated delays and hardcoded responses. These are called ONLY through TanStack Query hooks.

Current Mock Functions

  1. verifyOTP(phone, otp):

    • Simulates 1 second delay
    • Returns mock user, access token, refresh token
    • Automatically stores tokens in localStorage
    • Called via: useVerifyOTP() hook
  2. refreshAccessToken():

    • Simulates 500ms delay
    • Generates new mock tokens
    • Rotates refresh token (returns new one)
  3. logout():

    • Simulates 300ms delay
    • Returns success response

Migration to Production API

Each function has a TODO comment marking the integration point:

// TODO: Replace with actual API call
// const response = await axios.post('/api/auth/verify', { phone, otp });

Migration Steps:

  1. Replace mock logic with Axios POST calls
  2. Update response shape to match backend API contract
  3. Add error handling for network failures, invalid tokens, etc.
  4. Remove mock delays and console.log statements
  5. Update types if backend response differs from mock

Route Protection ✅

Route protection is fully implemented using layout-based guards to ensure users can only access appropriate routes based on their authentication status.

Implementation

Two-way protection:

  1. Protected Routes (/dashboard, etc.): Require authentication
  2. Auth Routes (/auth/*): Redirect to dashboard if already authenticated

Protected Layout

Located in src/app/(protected)/layout.tsx:

"use client";
 
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/lib/store/auth.store";
 
export default function ProtectedLayout({ children }) {
  const router = useRouter();
  const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
  const isLoading = useAuthStore((state) => state.isLoading);
 
  // Redirect to login if not authenticated
  useEffect(() => {
    if (!isLoading && !isAuthenticated) {
      router.push("/auth/login");
    }
  }, [isAuthenticated, isLoading, router]);
 
  // Show loading state while checking authentication
  if (isLoading) {
    return <div>Loading...</div>;
  }
 
  // Don't render protected content if not authenticated
  if (!isAuthenticated) {
    return null;
  }
 
  return <>{children}</>;
}

Features:

  • Checks isAuthenticated flag from Zustand store
  • Redirects unauthenticated users to /auth/login
  • Shows loading state during session restoration
  • Prevents flash of protected content
  • Includes logout functionality in header dropdown

Auth Guard

Located in src/components/guards/auth-guard.tsx:

"use client";
 
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/lib/store/auth.store";
 
export function AuthGuard({ children }) {
  const router = useRouter();
  const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
  const isLoading = useAuthStore((state) => state.isLoading);
 
  // Redirect authenticated users to dashboard
  useEffect(() => {
    if (!isLoading && isAuthenticated) {
      router.push("/dashboard");
    }
  }, [isAuthenticated, isLoading, router]);
 
  // Show loading state
  if (isLoading) {
    return <div>Loading...</div>;
  }
 
  // If authenticated, don't render auth pages (will redirect)
  if (isAuthenticated) {
    return null;
  }
 
  return <>{children}</>;
}

Usage in src/app/auth/layout.tsx:

import { AuthGuard } from "@/components/guards/auth-guard";
 
export default function AuthLayout({ children }) {
  return <AuthGuard>{children}</AuthGuard>;
}

Features:

  • Prevents authenticated users from accessing auth routes
  • Redirects to /dashboard if user tries to access /auth/login while logged in
  • Shows loading state during session check
  • Works automatically for all routes under /auth

Route Groups

The application uses Next.js route groups for organization:

src/app/
├── (protected)/          # Protected routes requiring authentication
│   ├── dashboard/
│   ├── tenants/
│   └── layout.tsx        # ProtectedLayout with auth check
├── auth/                 # Public auth routes
│   ├── login/
│   ├── verify/
│   ├── create-profile/
│   └── layout.tsx        # AuthLayout with AuthGuard
└── layout.tsx           # Root layout with providers

User Flow Examples

Scenario 1: Unauthenticated user tries to access dashboard

  1. User navigates to /dashboard
  2. ProtectedLayout checks isAuthenticatedfalse
  3. User redirected to /auth/login

Scenario 2: Authenticated user tries to access login

  1. User navigates to /auth/login
  2. AuthGuard checks isAuthenticatedtrue
  3. User redirected to /dashboard

Scenario 3: User logs out

  1. User clicks “Logout” in header dropdown
  2. useLogoutWithNavigation() calls logout API
  3. clearUser() removes tokens and resets auth state
  4. router.push("/auth/login") redirects to login
  5. User can now access auth routes again

Error Handling

Current Error Handling

  • Console logging: All errors are logged to console for debugging
  • TODO markers: Toast notifications planned but not implemented

Planned Improvements

  1. Toast Notifications: Replace console.error() with user-facing toast messages
  2. Error Types: Create custom error classes for auth failures (InvalidOTP, TokenExpired, NetworkError)
  3. Retry Logic: Implement exponential backoff for failed refresh attempts
  4. Fallback UI: Show error boundaries on auth failure instead of blank screens

Security Considerations

Current Implementation

Tokens stored in localStorage: Simpler than cookies, vulnerable to XSS but mitigated by CSP and React’s XSS protection ✅ Refresh token rotation: Each refresh returns a new refresh token, limiting attack window ✅ SSR-safe token access: typeof window checks prevent server-side leaks ✅ Separate access/refresh tokens: Limits exposure if access token is compromised

Future Enhancements

⚠️ HTTPS only: Ensure tokens only sent over HTTPS in production ⚠️ Token expiry validation: Add JWT decode to check expiry client-side before API call ⚠️ CSRF protection: Add CSRF tokens if switching to cookie-based storage ⚠️ Rate limiting: Backend should rate-limit OTP requests to prevent abuse

Testing Strategy (TODO)

Unit Tests

  • Test token CRUD operations in tokens.ts (localStorage mocking)
  • Test Zustand store actions (setUser, clearUser, logout, restoreSession)
  • Test mock API functions return correct shapes

Integration Tests

  • Test full login flow (phone → OTP → profile → dashboard)
  • Test session restoration on page reload
  • Test logout clears state and redirects

E2E Tests (Playwright/Cypress)

  • Test user can log in with valid OTP
  • Test invalid OTP shows error message
  • Test protected routes redirect to login when not authenticated
  • Test logout clears session and redirects

Troubleshooting

”User not authenticated” on page reload

Symptom: User logs in successfully but loses auth state on refresh.

Cause: restoreSession() not being called on app mount.

Fix: Ensure SessionProvider wraps app in layout.tsx:

<SessionProvider>{children}</SessionProvider>

Tokens not persisting

Symptom: getRefreshToken() returns null after setting it.

Cause: localStorage not available (SSR) or browser privacy settings.

Fix: Check typeof window !== "undefined" before accessing localStorage. Test in incognito mode to verify privacy settings.

Mock API not returning data

Symptom: verifyOTP() throws error or returns undefined.

Cause: Mock delay not awaited or response shape mismatch.

Fix: Ensure await is used when calling service functions. Check console for mock response logs.

API Contract Reference

POST /api/auth/verify

Request:

{
  "phone": "+919876543210",
  "otp": "123456"
}

Response:

{
  "success": true,
  "user": {
    "id": "user-123",
    "phone": "+919876543210",
    "name": "John Doe",
    "email": "john@example.com"
  },
  "accessToken": "eyJhbGciOiJIUzI1NiIs...",
  "refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}

POST /api/auth/refresh

Request:

{
  "refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}

Response:

{
  "success": true,
  "accessToken": "eyJhbGciOiJIUzI1NiIs...",
  "refreshToken": "eyJhbGciOiJIUzI1NiIs..." // Rotated token
}

POST /api/auth/logout

Request:

{
  "refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}

Response:

{
  "success": true,
  "message": "Logged out successfully"
}

Migration Checklist

Before replacing mocks with production API:

  • Backend API endpoints deployed and tested
  • Frontend Axios client configured with base URL (NEXT_PUBLIC_API_URL)
  • Error handling implemented for 401, 403, 500 errors
  • Token refresh interceptor added to Axios instance
  • Route guards implemented for protected pages
  • Toast notifications replace console.log statements
  • Remove all mock delays and TODO comments
  • Update types to match backend response shapes
  • Test full auth flow end-to-end
  • Verify session restoration works on page reload
  • Test logout clears tokens and redirects correctly

Additional Resources