Start HereOrdered learning track

Auth State Machine

Learn React Authentication, Authorization, Identity & Permission/ACL - Part 007

Auth state machine untuk React apps: dari anonymous, authenticating, authenticated, refreshing, expired, revoked, forbidden, degraded, sampai logout; lengkap dengan invariant, transition table, reducer, dan integrasi router.

15 min read2842 words
PrevNext
Lesson 07130 lesson track01–24 Start Here
#react#authentication#authorization#state-machine+2 more

Auth State Machine

Auth di React sering gagal bukan karena developer tidak tahu cara membuat login form. Ia gagal karena sistem auth diperlakukan sebagai beberapa boolean yang tidak punya kontrak.

Biasanya bentuknya seperti ini:

const { user, isLoading, isAuthenticated } = useAuth();

Lalu setelah beberapa minggu, state bertambah:

const {
  user,
  token,
  isLoading,
  isAuthenticated,
  isRefreshing,
  isExpired,
  isLoggingOut,
  error,
} = useAuth();

Kemudian muncul bug:

  • user sudah logout di tab lain, tapi tab ini masih menampilkan dashboard;
  • token sedang refresh, tetapi request lama direplay dua kali;
  • isAuthenticated === true, tetapi permission belum dimuat;
  • callback OAuth gagal, tetapi UI kembali ke halaman sebelumnya seolah-olah login berhasil;
  • role user dicabut, tapi button destructive masih terlihat sampai reload;
  • network lambat membuat UI menampilkan layar protected sebelum bootstrap selesai;
  • 401 diperlakukan sama dengan 403;
  • logout hanya menghapus state React, bukan mencabut session server.

Masalahnya bukan di React. Masalahnya adalah model state yang tidak eksplisit.

Part ini membangun auth sebagai state machine: daftar state yang mungkin, event yang boleh mengubah state, transisi yang valid, side effect yang dikontrol, dan invariant yang harus selalu benar.


1. Prinsip utama

Auth state machine menjawab tiga pertanyaan:

1. Sistem sedang berada di state apa?
2. Event apa yang boleh terjadi dari state itu?
3. Setelah event terjadi, state berikutnya apa dan side effect apa yang boleh dijalankan?

Tanpa state machine, auth cenderung menjadi boolean soup.

Boolean soup:
- isLoading
- isLoggedIn
- hasToken
- isRefreshing
- isExpired
- isForbidden
- isLoggingOut
- hasError

Boolean soup mudah menghasilkan kombinasi mustahil:

isLoading = true
isAuthenticated = true
isExpired = true
isRefreshing = false
user = null
error = null

State machine membuat kombinasi mustahil menjadi tidak representable.

state = "authenticated"
session = {...}
permissions = {...}

atau:

state = "refreshing"
previousSession = {...}
refreshStartedAt = ...

atau:

state = "anonymous"
reason = "no_session"

Dalam sistem auth production, representasi state yang benar adalah bagian dari security design.


2. Mental model: auth bukan satu state

Auth memiliki beberapa lapisan state.

Tetapi React application tidak boleh membocorkan semua kompleksitas ini ke semua component. Kita butuh state machine yang cukup kaya untuk benar, tetapi cukup sederhana untuk digunakan.

Rule praktis:

Auth state machine internal boleh detail.
Public auth API harus kecil, stabil, dan typed.

3. State utama yang akan kita gunakan

Untuk React app production, minimal state machine yang sehat biasanya memiliki state berikut.

bootstrapping
anonymous
authenticating
handling_callback
authenticated
loading_permissions
ready
refreshing
expired
revoked
forbidden
switching_tenant
logging_out
degraded
failed

Tidak semua aplikasi butuh semua state. Tetapi jika aplikasi punya OAuth/OIDC, multi-tab, tenant, permission, refresh token, dan protected data route, state-state ini akan muncul cepat atau lambat.

Lebih baik kita beri nama eksplisit sejak awal.


4. Diagram state machine dasar

Perhatikan dua hal:

  1. authenticated belum tentu ready.
  2. ready belum tentu tetap valid selamanya.

authenticated hanya berarti identitas/session berhasil dibuktikan. Untuk menampilkan aplikasi secara aman, sering kali kita masih butuh permission, tenant context, dan initial user profile yang benar.


5. Kenapa authenticated dan authorized harus dipisah

Kesalahan umum:

if (!isAuthenticated) {
  return <LoginPage />;
}

return <AdminPage />;

Ini mencampur dua pertanyaan:

Authentication: apakah user punya session valid?
Authorization: apakah user boleh membuka resource/action ini?

State machine yang lebih sehat:

anonymous         -> belum login
bootstrapping     -> belum tahu
authenticated     -> session valid, permission belum tentu siap
ready             -> session valid + auth context minimal siap
forbidden         -> session valid, akses tidak diizinkan

Dengan pemisahan ini, UI bisa membedakan:

401 unauthenticated -> login/re-auth
403 forbidden       -> show no-access/request-access
unknown             -> restrict or loading, never allow

Pemisahan ini juga membuat audit lebih jelas. “User tidak login” dan “user login tetapi tidak punya permission” adalah dua event berbeda.


6. Typed state: jangan mulai dari boolean

Dalam TypeScript, gunakan discriminated union.

type AuthState =
  | {
      tag: 'bootstrapping';
      startedAt: number;
    }
  | {
      tag: 'anonymous';
      reason: 'no_session' | 'logout' | 'session_expired' | 'callback_cancelled';
    }
  | {
      tag: 'authenticating';
      provider: 'oidc' | 'password' | 'sso';
      returnTo?: string;
    }
  | {
      tag: 'handling_callback';
      provider: 'oidc';
      returnTo?: string;
    }
  | {
      tag: 'authenticated';
      session: AuthSession;
      user: AuthUser;
    }
  | {
      tag: 'loading_permissions';
      session: AuthSession;
      user: AuthUser;
      tenantId?: string;
    }
  | {
      tag: 'ready';
      session: AuthSession;
      user: AuthUser;
      tenant: TenantContext;
      permissions: PermissionSnapshot;
    }
  | {
      tag: 'refreshing';
      previous: ReadyAuthState;
      refreshStartedAt: number;
      reason: 'scheduled' | 'request_401' | 'visibility_resume' | 'manual';
    }
  | {
      tag: 'expired';
      previousUser?: AuthUser;
      expiredAt?: number;
    }
  | {
      tag: 'revoked';
      previousUser?: AuthUser;
      reason: 'server_revoked' | 'refresh_reuse_detected' | 'device_revoked' | 'password_changed';
    }
  | {
      tag: 'forbidden';
      user: AuthUser;
      tenant?: TenantContext;
      reason: ForbiddenReason;
    }
  | {
      tag: 'switching_tenant';
      previous: ReadyAuthState;
      targetTenantId: string;
    }
  | {
      tag: 'logging_out';
      previous?: AuthState;
      reason: 'user_requested' | 'session_expired' | 'security_event' | 'forced';
    }
  | {
      tag: 'degraded';
      previous?: ReadyAuthState;
      reason: 'network_unavailable' | 'permission_service_unavailable' | 'identity_provider_unavailable';
      recoverable: boolean;
    }
  | {
      tag: 'failed';
      error: AuthError;
      recoverable: boolean;
    };

Support type:

type AuthSession = {
  sessionId: string;
  issuedAt: number;
  expiresAt: number;
  refreshAfter?: number;
  authTime?: number;
  assuranceLevel?: 'low' | 'medium' | 'high';
};

type AuthUser = {
  id: string;
  displayName: string;
  email?: string;
};

type TenantContext = {
  id: string;
  slug: string;
  displayName: string;
};

type PermissionSnapshot = {
  version: string;
  loadedAt: number;
  tenantId?: string;
  actions: Record<string, PermissionDecision>;
};

type PermissionDecision =
  | { allowed: true; reason?: string }
  | { allowed: false; reason: string };

type ReadyAuthState = Extract<AuthState, { tag: 'ready' }>;

type ForbiddenReason =
  | 'missing_permission'
  | 'tenant_membership_required'
  | 'account_suspended'
  | 'step_up_required'
  | 'resource_not_accessible';

type AuthError = {
  code: string;
  message: string;
  cause?: unknown;
};

Tujuan typing ini bukan “TypeScript fancy”. Tujuannya adalah membuat state invalid tidak bisa direpresentasikan.

Misalnya, ready selalu punya session, user, tenant, dan permissions. Component yang butuh permission tidak perlu menebak apakah permission sudah dimuat.


7. Public API jangan membocorkan semua state

Internal state boleh kaya. Tetapi API ke component harus ringkas.

type AuthView =
  | {
      status: 'unknown';
      canRenderProtectedShell: false;
    }
  | {
      status: 'anonymous';
      canRenderProtectedShell: false;
      login: (options?: LoginOptions) => Promise<void>;
    }
  | {
      status: 'ready';
      canRenderProtectedShell: true;
      user: AuthUser;
      tenant: TenantContext;
      can: CanFunction;
      logout: () => Promise<void>;
    }
  | {
      status: 'forbidden';
      canRenderProtectedShell: false;
      reason: ForbiddenReason;
      logout: () => Promise<void>;
    }
  | {
      status: 'recoverable_error';
      canRenderProtectedShell: false;
      retry: () => Promise<void>;
      logout: () => Promise<void>;
    };

Jangan semua component tahu refreshing, handling_callback, atau switching_tenant, kecuali memang perlu.

Pattern sehat:

Internal state machine detail.
Public view model sederhana.
Route boundary memakai internal detail.
Leaf component memakai public view model.

8. Event lebih penting dari setter

Anti-pattern:

setIsAuthenticated(true);
setUser(user);
setToken(token);
setIsLoading(false);

Setter bebas membuat state tidak konsisten.

State machine memakai event:

type AuthEvent =
  | { type: 'BOOTSTRAP_STARTED'; now: number }
  | { type: 'BOOTSTRAP_NO_SESSION' }
  | { type: 'BOOTSTRAP_SESSION_FOUND'; session: AuthSession; user: AuthUser }
  | { type: 'LOGIN_REQUESTED'; provider: 'oidc' | 'password' | 'sso'; returnTo?: string }
  | { type: 'CALLBACK_STARTED'; returnTo?: string }
  | { type: 'CALLBACK_SUCCEEDED'; session: AuthSession; user: AuthUser }
  | { type: 'CALLBACK_FAILED'; error: AuthError }
  | { type: 'PERMISSIONS_LOAD_STARTED'; tenantId?: string }
  | { type: 'PERMISSIONS_LOADED'; tenant: TenantContext; permissions: PermissionSnapshot }
  | { type: 'PERMISSIONS_DENIED'; reason: ForbiddenReason }
  | { type: 'REFRESH_STARTED'; now: number; reason: ReadyAuthState['tag'] extends never ? never : 'scheduled' | 'request_401' | 'visibility_resume' | 'manual' }
  | { type: 'REFRESH_SUCCEEDED'; session: AuthSession; permissions?: PermissionSnapshot }
  | { type: 'REFRESH_EXPIRED'; expiredAt?: number }
  | { type: 'SESSION_REVOKED'; reason: Extract<AuthState, { tag: 'revoked' }>['reason'] }
  | { type: 'TENANT_SWITCH_REQUESTED'; targetTenantId: string }
  | { type: 'TENANT_SWITCH_SUCCEEDED'; session: AuthSession; tenant: TenantContext; permissions: PermissionSnapshot }
  | { type: 'TENANT_SWITCH_FORBIDDEN'; reason: ForbiddenReason }
  | { type: 'LOGOUT_REQUESTED'; reason: Extract<AuthState, { tag: 'logging_out' }>['reason'] }
  | { type: 'LOGOUT_COMPLETED' }
  | { type: 'RECOVERABLE_FAILURE'; reason: Extract<AuthState, { tag: 'degraded' }>['reason'] }
  | { type: 'FAILURE'; error: AuthError; recoverable: boolean }
  | { type: 'RESET' };

Event memberi sistem bahasa yang sama dengan audit log, metrics, dan test case.


9. Reducer: transisi murni, side effect di luar

State transition harus bisa dites tanpa network.

function authReducer(state: AuthState, event: AuthEvent): AuthState {
  switch (state.tag) {
    case 'bootstrapping': {
      switch (event.type) {
        case 'BOOTSTRAP_NO_SESSION':
          return { tag: 'anonymous', reason: 'no_session' };

        case 'BOOTSTRAP_SESSION_FOUND':
          return {
            tag: 'authenticated',
            session: event.session,
            user: event.user,
          };

        case 'FAILURE':
          return {
            tag: 'failed',
            error: event.error,
            recoverable: event.recoverable,
          };

        default:
          return state;
      }
    }

    case 'anonymous': {
      if (event.type === 'LOGIN_REQUESTED') {
        return {
          tag: 'authenticating',
          provider: event.provider,
          returnTo: event.returnTo,
        };
      }
      return state;
    }

    case 'authenticating': {
      if (event.type === 'CALLBACK_STARTED') {
        return {
          tag: 'handling_callback',
          provider: 'oidc',
          returnTo: event.returnTo ?? state.returnTo,
        };
      }
      return state;
    }

    case 'handling_callback': {
      switch (event.type) {
        case 'CALLBACK_SUCCEEDED':
          return {
            tag: 'authenticated',
            session: event.session,
            user: event.user,
          };

        case 'CALLBACK_FAILED':
          return {
            tag: 'failed',
            error: event.error,
            recoverable: true,
          };

        default:
          return state;
      }
    }

    case 'authenticated': {
      if (event.type === 'PERMISSIONS_LOAD_STARTED') {
        return {
          tag: 'loading_permissions',
          session: state.session,
          user: state.user,
          tenantId: event.tenantId,
        };
      }
      return state;
    }

    case 'loading_permissions': {
      switch (event.type) {
        case 'PERMISSIONS_LOADED':
          return {
            tag: 'ready',
            session: state.session,
            user: state.user,
            tenant: event.tenant,
            permissions: event.permissions,
          };

        case 'PERMISSIONS_DENIED':
          return {
            tag: 'forbidden',
            user: state.user,
            reason: event.reason,
          };

        case 'RECOVERABLE_FAILURE':
          return {
            tag: 'degraded',
            reason: event.reason,
            recoverable: true,
          };

        default:
          return state;
      }
    }

    case 'ready': {
      switch (event.type) {
        case 'REFRESH_STARTED':
          return {
            tag: 'refreshing',
            previous: state,
            refreshStartedAt: event.now,
            reason: event.reason,
          };

        case 'TENANT_SWITCH_REQUESTED':
          return {
            tag: 'switching_tenant',
            previous: state,
            targetTenantId: event.targetTenantId,
          };

        case 'LOGOUT_REQUESTED':
          return {
            tag: 'logging_out',
            previous: state,
            reason: event.reason,
          };

        case 'SESSION_REVOKED':
          return {
            tag: 'revoked',
            previousUser: state.user,
            reason: event.reason,
          };

        default:
          return state;
      }
    }

    case 'refreshing': {
      switch (event.type) {
        case 'REFRESH_SUCCEEDED':
          return {
            tag: 'ready',
            ...state.previous,
            session: event.session,
            permissions: event.permissions ?? state.previous.permissions,
          };

        case 'REFRESH_EXPIRED':
          return {
            tag: 'expired',
            previousUser: state.previous.user,
            expiredAt: event.expiredAt,
          };

        case 'SESSION_REVOKED':
          return {
            tag: 'revoked',
            previousUser: state.previous.user,
            reason: event.reason,
          };

        case 'RECOVERABLE_FAILURE':
          return {
            tag: 'degraded',
            previous: state.previous,
            reason: event.reason,
            recoverable: true,
          };

        default:
          return state;
      }
    }

    case 'switching_tenant': {
      switch (event.type) {
        case 'TENANT_SWITCH_SUCCEEDED':
          return {
            tag: 'ready',
            session: event.session,
            user: state.previous.user,
            tenant: event.tenant,
            permissions: event.permissions,
          };

        case 'TENANT_SWITCH_FORBIDDEN':
          return {
            tag: 'forbidden',
            user: state.previous.user,
            tenant: state.previous.tenant,
            reason: event.reason,
          };

        default:
          return state;
      }
    }

    case 'logging_out': {
      if (event.type === 'LOGOUT_COMPLETED') {
        return { tag: 'anonymous', reason: 'logout' };
      }
      return state;
    }

    case 'expired':
    case 'revoked':
    case 'forbidden':
    case 'degraded':
    case 'failed': {
      if (event.type === 'LOGOUT_REQUESTED') {
        return {
          tag: 'logging_out',
          previous: state,
          reason: event.reason,
        };
      }

      if (event.type === 'RESET') {
        return { tag: 'anonymous', reason: 'no_session' };
      }

      return state;
    }
  }
}

Beberapa detail sengaja dibuat eksplisit:

  • ready -> refreshing menyimpan previous agar UI bisa membuat keputusan apakah boleh tetap render atau harus freeze.
  • refreshing -> ready mengganti session, tetapi permission boleh tetap dipakai hanya jika contract backend menjamin permission tidak berubah karena refresh.
  • SESSION_REVOKED bisa datang dari state ready atau refreshing.
  • expired, revoked, forbidden, failed, dan degraded tidak otomatis menjadi anonymous. Itu penting agar UI bisa memberi recovery yang benar.

10. Transition table

Gunakan tabel ini sebagai review checklist.

Current StateEventNext StateCatatan
bootstrappingno sessionanonymousSafe. Jangan render protected shell.
bootstrappingsession foundauthenticatedBelum tentu permission siap.
authenticatedload permissionsloading_permissionsJangan expose sensitive action.
loading_permissionsloadedreadyProtected app boleh render.
loading_permissionsdeniedforbiddenUser valid, akses tidak valid.
readyrefresh startrefreshingHindari refresh paralel.
refreshingsuccessreadyReplace session atomically.
refreshingexpiredexpiredRe-auth diperlukan.
refreshingrevokedrevokedSecurity-sensitive path.
readytenant switchswitching_tenantClear tenant-scoped cache.
switching_tenantsuccessreadyPermission snapshot harus tenant-specific.
switching_tenantforbiddenforbiddenJangan fallback ke tenant lama seolah sukses.
readylogoutlogging_outStop request, clear cache, revoke if needed.
logging_outcompletedanonymousBaru aman kembali ke login screen.

Tabel transisi adalah dokumen desain. Kalau ada bug auth, biasanya ada transisi yang tidak terdefinisi atau dibiarkan implicit.


11. Side effect map

Reducer harus murni. Side effect dijalankan oleh command layer.

Contoh effect map:

State enteredSide effect
bootstrappingCall /session or inspect server-provided bootstrap data.
authenticatingRedirect to IdP or call credential login endpoint.
handling_callbackExchange code/session with backend; validate state/nonce server-side where applicable.
loading_permissionsFetch /me, /permissions, tenant context.
refreshingRun refresh with single-flight lock.
switching_tenantCall tenant switch endpoint; clear tenant-scoped cache.
logging_outRevoke server session, clear local state, broadcast logout, clear cache, redirect.
revokedStop API replay; show forced logout/security message.

Jangan menaruh network call di reducer. Reducer yang punya side effect sulit dites dan sulit diaudit.


12. Bootstrap flow

Bootstrap adalah fase paling rawan flash-of-protected-content.

Bad pattern:

function App() {
  const user = readUserFromLocalStorage();

  return user ? <Dashboard /> : <Login />;
}

Masalah:

  • localStorage bisa stale;
  • user object bukan session proof;
  • permission belum diverifikasi;
  • tenant context belum dipastikan;
  • browser cache bisa menyimpan data lama;
  • UI protected bisa render sebelum server check.

Bootstrap yang lebih sehat:

Jika /session mengembalikan 401:

Invariant:

Protected shell tidak boleh render sebelum bootstrap menghasilkan ready atau route-specific authorization selesai.

13. Authenticated tidak otomatis ready

Banyak aplikasi gagal di sini.

authenticated = identity/session valid
ready = identity/session valid + context minimal untuk menjalankan app aman

Contoh context minimal:

  • current tenant sudah dipilih;
  • membership tenant sudah verified;
  • permission snapshot sudah dimuat;
  • user status tidak suspended;
  • required MFA/step-up status cukup untuk route yang diminta;
  • profile state tidak butuh mandatory onboarding sebelum masuk app;
  • initial cache tidak membawa data tenant lama.

Maka state path sehat:

bootstrapping -> authenticated -> loading_permissions -> ready

Bukan:

bootstrapping -> authenticated -> render everything

14. Refresh flow

Refresh adalah distributed race problem.

Hal yang bisa terjadi:

  • access token expired saat 5 request berjalan paralel;
  • dua tab mencoba refresh bersamaan;
  • refresh berhasil tetapi permission sebenarnya berubah;
  • refresh token reuse terdeteksi;
  • network timeout tetapi request asli menunggu;
  • user logout saat refresh sedang berlangsung;
  • backend mengembalikan 401 untuk session expired dan 403 untuk permission denied;
  • refresh loop tidak pernah berhenti.

State machine membantu membuat refresh eksplisit.

Rule:

Replay original request at most once after a successful refresh.

Jika masih 401 setelah refresh sukses, jangan refresh lagi secara infinite. Masuk ke expired, revoked, atau failed sesuai response.


15. Single-flight refresh

Refresh harus single-flight dalam satu runtime.

Bad pattern:

api.interceptors.response.use(undefined, async (error) => {
  if (error.status === 401) {
    await refresh();
    return api.request(error.config);
  }
  throw error;
});

Jika 20 request menerima 401, akan ada 20 refresh call.

Lebih sehat:

let refreshPromise: Promise<void> | null = null;

async function refreshOnce(reason: RefreshReason): Promise<void> {
  if (!refreshPromise) {
    refreshPromise = performRefresh(reason).finally(() => {
      refreshPromise = null;
    });
  }

  return refreshPromise;
}

Dengan state machine:

async function performRefresh(reason: RefreshReason) {
  const state = authStore.getState();

  if (state.tag !== 'ready') {
    throw new Error(`Cannot refresh from state ${state.tag}`);
  }

  authStore.dispatch({
    type: 'REFRESH_STARTED',
    now: Date.now(),
    reason,
  });

  try {
    const result = await sessionApi.refresh();

    authStore.dispatch({
      type: 'REFRESH_SUCCEEDED',
      session: result.session,
      permissions: result.permissions,
    });
  } catch (error) {
    const classified = classifyRefreshError(error);

    switch (classified.kind) {
      case 'expired':
        authStore.dispatch({ type: 'REFRESH_EXPIRED', expiredAt: Date.now() });
        break;

      case 'revoked':
        authStore.dispatch({ type: 'SESSION_REVOKED', reason: classified.reason });
        break;

      case 'recoverable':
        authStore.dispatch({ type: 'RECOVERABLE_FAILURE', reason: classified.reason });
        break;

      default:
        authStore.dispatch({
          type: 'FAILURE',
          error: classified.error,
          recoverable: false,
        });
    }
  }
}

Single-flight lint rule mental:

There must be one refresh owner.
All other requests wait or fail explicitly.

16. Refresh and multi-tab

Multi-tab akan dibahas lebih dalam pada Part 018, tetapi state machine harus disiapkan sejak sekarang.

Problem:

Tab A refreshes session.
Tab B still has old token/session state.
Tab C logs out.
Tab A still replays request after logout.

State machine harus menerima event eksternal:

type CrossTabAuthEvent =
  | { type: 'REMOTE_LOGOUT'; at: number }
  | { type: 'REMOTE_SESSION_REFRESHED'; at: number }
  | { type: 'REMOTE_SESSION_REVOKED'; at: number; reason: string }
  | { type: 'REMOTE_TENANT_SWITCHED'; at: number; tenantId: string };

Mapping:

Remote eventLocal action
REMOTE_LOGOUTStop request, clear local cache, transition to anonymous or logging_out.
REMOTE_SESSION_REFRESHEDRe-bootstrap or fetch current session. Jangan blindly copy secret token antar tab.
REMOTE_SESSION_REVOKEDTransition to revoked.
REMOTE_TENANT_SWITCHEDClear tenant-scoped cache and revalidate.

Jangan broadcast sensitive token melalui BroadcastChannel atau localStorage event. Broadcast event metadata, bukan credential.


17. Logout flow

Logout bukan sekadar:

localStorage.removeItem('token');
setUser(null);

Logout adalah state transition dengan side effects.

Invariant logout:

After logout is requested, new protected requests must not be started using the previous session.

Artinya, begitu state masuk logging_out, API client harus menolak request protected baru.

function assertCanStartProtectedRequest(state: AuthState) {
  if (state.tag !== 'ready' && state.tag !== 'refreshing') {
    throw new AuthClientError('auth_not_ready');
  }

  if (state.tag === 'refreshing') {
    // Depends on app policy. Usually wait for refresh owner.
    throw new AuthClientError('auth_refresh_in_progress');
  }
}

18. Forbidden bukan expired

403 dan 401 tidak boleh dicampur.

401 -> authentication/session problem
403 -> authorization problem

Jika 403 diperlakukan sebagai expired, user akan dipaksa login ulang untuk masalah permission. Itu buruk untuk UX dan observability.

Jika 401 diperlakukan sebagai forbidden, user tidak diberi recovery untuk re-auth.

Mapping sehat:

HTTP / API resultState implicationUI
401 session_missinganonymousLogin.
401 session_expiredexpired or refreshingRefresh/re-auth.
401 session_revokedrevokedForced logout/security message.
403 missing_permissionforbiddenNo access/request access.
403 tenant_membership_requiredforbiddenTenant switch/request membership.
403 step_up_requiredroute-specific step-up stateMFA/re-auth.
404 for hidden resourceroute-level not foundAvoid resource enumeration.

State machine harus menerima error semantics yang cukup jelas dari backend. Jika backend hanya mengembalikan error, frontend akan menebak.


19. Permission loading as state

Permission loading harus eksplisit karena UI exposure bergantung padanya.

Anti-pattern:

function DeleteButton() {
  const { user } = useAuth();

  if (user?.role === 'admin') {
    return <button>Delete</button>;
  }

  return null;
}

Lebih sehat:

function DeleteCaseButton({ caseId }: { caseId: string }) {
  const decision = useCan('case.delete', { type: 'case', id: caseId });

  if (decision.status === 'unknown') {
    return null;
  }

  if (!decision.allowed) {
    return null;
  }

  return <button>Delete case</button>;
}

Tetapi useCan sendiri harus bergantung pada state machine.

function canFromState(
  state: AuthState,
  action: string,
  resource: PermissionResource,
): PermissionDecision & { status: 'known' | 'unknown' } {
  if (state.tag !== 'ready') {
    return {
      status: 'unknown',
      allowed: false,
      reason: 'auth_not_ready',
    };
  }

  return evaluatePermission(state.permissions, action, resource);
}

Invariant:

Permission unknown must not render as allowed.

20. Tenant switch state

Multi-tenant auth sering rusak karena tenant dianggap filter UI, bukan security context.

Tenant switch harus menjadi state transition.

Saat tenant switch:

  • block tenant-scoped mutations;
  • clear tenant-scoped query cache;
  • cancel in-flight tenant-scoped requests;
  • fetch new membership/permission snapshot;
  • update route if URL contains tenant slug/id;
  • audit tenant switch if relevant;
  • never display previous tenant data as current tenant data.

Example:

async function switchTenant(targetTenantId: string) {
  const state = authStore.getState();

  if (state.tag !== 'ready') {
    throw new Error('Cannot switch tenant unless auth is ready');
  }

  authStore.dispatch({ type: 'TENANT_SWITCH_REQUESTED', targetTenantId });
  queryClient.cancelQueries({ predicate: isTenantScopedQuery });
  queryClient.removeQueries({ predicate: isTenantScopedQuery });

  try {
    const result = await sessionApi.switchTenant(targetTenantId);

    authStore.dispatch({
      type: 'TENANT_SWITCH_SUCCEEDED',
      session: result.session,
      tenant: result.tenant,
      permissions: result.permissions,
    });
  } catch (error) {
    const classified = classifyTenantSwitchError(error);

    if (classified.kind === 'forbidden') {
      authStore.dispatch({
        type: 'TENANT_SWITCH_FORBIDDEN',
        reason: classified.reason,
      });
      return;
    }

    authStore.dispatch({
      type: 'FAILURE',
      error: classified.error,
      recoverable: true,
    });
  }
}

21. Degraded state

Production auth harus punya degraded state.

Contoh:

  • IdP unavailable;
  • permission service timeout;
  • network offline;
  • BFF intermittent failure;
  • /session endpoint lambat;
  • refresh endpoint timeout.

Tidak semua failure berarti logout.

expired/revoked = session validity problem
degraded = dependency/runtime problem

Contoh policy:

FailureDegraded allowed?Reason
Permission service unavailable before app readyNo sensitive actionUnknown permission must deny.
Refresh timeout while existing session still within graceMaybe read-only modeDepends on backend contract.
IdP unavailable during loginPublic/login errorUser not authenticated.
Audit service unavailable for destructive actionMaybe block actionDepends on compliance requirement.
Network offline with stale cached dataRead-only offline shellMutations blocked.

Degraded state harus eksplisit agar tidak berubah menjadi “catch all error”.


22. Route integration

React Router Data/Framework Mode membuat auth bisa diperiksa sebelum component render. Itu lebih sehat dibanding guard yang hanya berjalan setelah route component mount.

Contoh konseptual loader:

export async function protectedLoader({ request }: LoaderFunctionArgs) {
  const session = await authServer.requireSession(request);

  if (!session) {
    throw redirect(`/login?returnTo=${encodeURIComponent(new URL(request.url).pathname)}`);
  }

  const decision = await policy.can(session.userId, 'case.read', {
    type: 'case',
    id: params.caseId,
  });

  if (!decision.allowed) {
    throw new Response('Forbidden', { status: 403 });
  }

  return { session, decision };
}

Untuk SPA murni, loader client-side tetap tidak menjadi security boundary. Tetapi loader tetap berguna sebagai early exposure boundary:

Client loader prevents accidental UI/data exposure.
Server/API still enforces real authorization.

23. React integration pattern

Minimal store:

type Listener = () => void;

class AuthStore {
  private state: AuthState = { tag: 'bootstrapping', startedAt: Date.now() };
  private listeners = new Set<Listener>();

  getState = () => this.state;

  subscribe = (listener: Listener) => {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  };

  dispatch = (event: AuthEvent) => {
    const next = authReducer(this.state, event);

    if (next !== this.state) {
      this.state = next;
      for (const listener of this.listeners) listener();
    }
  };
}

export const authStore = new AuthStore();

Hook dengan useSyncExternalStore:

import { useSyncExternalStore } from 'react';

export function useAuthState(): AuthState {
  return useSyncExternalStore(
    authStore.subscribe,
    authStore.getState,
    authStore.getState,
  );
}

View selector:

export function useAuthView(): AuthView {
  const state = useAuthState();

  switch (state.tag) {
    case 'bootstrapping':
    case 'authenticating':
    case 'handling_callback':
    case 'loading_permissions':
    case 'refreshing':
    case 'switching_tenant':
    case 'logging_out':
      return {
        status: 'unknown',
        canRenderProtectedShell: false,
      };

    case 'anonymous':
      return {
        status: 'anonymous',
        canRenderProtectedShell: false,
        login,
      };

    case 'ready':
      return {
        status: 'ready',
        canRenderProtectedShell: true,
        user: state.user,
        tenant: state.tenant,
        can: makeCan(state.permissions),
        logout,
      };

    case 'forbidden':
      return {
        status: 'forbidden',
        canRenderProtectedShell: false,
        reason: state.reason,
        logout,
      };

    case 'expired':
    case 'revoked':
    case 'degraded':
    case 'failed':
      return {
        status: 'recoverable_error',
        canRenderProtectedShell: false,
        retry: bootstrap,
        logout,
      };
  }
}

Perhatikan policy ini mungkin terlalu konservatif untuk beberapa app. Misalnya saat refreshing, beberapa app tetap render shell lama. Itu boleh, asalkan sensitive mutation diblok dan data stale diberi status jelas.


24. App shell rendering policy

Tentukan rendering policy per state.

StateProtected shell?Sensitive action?Data fetch?
bootstrappingNoNoSession bootstrap only
anonymousNoNoPublic only
authenticatingNoNoAuth redirect only
handling_callbackNoNoCallback exchange only
authenticatedNoNoPermission/session context only
loading_permissionsNoNoPermission fetch only
readyYesBased on permissionYes
refreshingMaybe stale shellUsually no new mutationWait/retry policy
expiredNoNoRe-auth only
revokedNoNoLogout/security flow only
forbiddenNo protected resourceNoAccess request maybe
switching_tenantMaybe neutral shellNoTenant context only
logging_outNoNoLogout cleanup only
degradedMaybe read-onlyNo or limitedDepends on policy
failedNoNoRetry/reset only

Tabel ini harus diputuskan oleh tim. Jangan biarkan setiap component memutuskan sendiri.


25. API client policy

API client harus membaca state machine, bukan local variable token acak.

async function authorizedFetch(input: RequestInfo, init?: RequestInit) {
  const state = authStore.getState();

  if (state.tag === 'anonymous' || state.tag === 'expired' || state.tag === 'revoked') {
    throw new AuthClientError('unauthenticated');
  }

  if (state.tag === 'logging_out') {
    throw new AuthClientError('logout_in_progress');
  }

  if (state.tag === 'bootstrapping' || state.tag === 'loading_permissions') {
    throw new AuthClientError('auth_not_ready');
  }

  if (state.tag === 'refreshing') {
    await waitUntilRefreshSettled();
    return authorizedFetch(input, init);
  }

  if (state.tag !== 'ready') {
    throw new AuthClientError(`invalid_auth_state:${state.tag}`);
  }

  const response = await fetch(input, {
    ...init,
    credentials: 'include',
  });

  if (response.status === 401) {
    await refreshOnce('request_401');
    return fetch(input, {
      ...init,
      credentials: 'include',
    });
  }

  return response;
}

Untuk cookie session, credentials: 'include' mungkin perlu tergantung same-origin/cross-origin design. Untuk bearer token, header injection terjadi di boundary ini. Namun pattern utamanya sama: API client harus tunduk pada auth state machine.


26. Jangan menyimpan derived state secara sembarangan

Misalnya:

const isAdmin = user.roles.includes('admin');

Kalau isAdmin disimpan di banyak tempat, permission drift akan terjadi.

Lebih sehat:

const decision = can('case.delete', { type: 'case', id: caseId });

Derived booleans boleh dibuat sebagai selector, bukan sebagai state permanen.

function selectIsReady(state: AuthState): state is ReadyAuthState {
  return state.tag === 'ready';
}

function selectCurrentUser(state: AuthState): AuthUser | null {
  return state.tag === 'ready' ? state.user : null;
}

Rule:

Store facts and snapshots. Derive convenience booleans.

27. Race condition catalog

Beberapa race yang harus dites:

RaceRisikoState machine defense
Bootstrap vs route renderProtected flashbootstrapping blocks protected shell.
Refresh vs logoutRequest replay after logoutlogging_out blocks replay.
Multi request 401Refresh stormSingle-flight refresh.
Tenant switch vs in-flight dataCross-tenant displayCancel/clear tenant-scoped cache.
Permission changed vs cached UIStale privilegePermission version + invalidation.
Callback duplicateCode exchange replayhandling_callback idempotency + backend validation.
User closes tab during logoutServer session remainsServer expiry/revocation; best effort local cleanup.
Offline during refreshInfinite retrydegraded with bounded retry.
Back button after logoutSensitive cached pageCache-control + client clear + no protected shell.

State machine tidak menghapus semua race. Ia membuat race terlihat dan dapat dites.


28. Testing transition

Test reducer seperti test parser atau database engine. Tidak perlu browser.

describe('authReducer', () => {
  it('does not become ready directly from bootstrapping without permissions', () => {
    const initial: AuthState = { tag: 'bootstrapping', startedAt: 1 };

    const next = authReducer(initial, {
      type: 'BOOTSTRAP_SESSION_FOUND',
      session: fakeSession(),
      user: fakeUser(),
    });

    expect(next.tag).toBe('authenticated');
  });

  it('moves from ready to refreshing on scheduled refresh', () => {
    const ready = fakeReadyState();

    const next = authReducer(ready, {
      type: 'REFRESH_STARTED',
      now: 100,
      reason: 'scheduled',
    });

    expect(next.tag).toBe('refreshing');
  });

  it('does not allow protected app shell during unknown states', () => {
    const states: AuthState[] = [
      { tag: 'bootstrapping', startedAt: 1 },
      { tag: 'anonymous', reason: 'no_session' },
      { tag: 'expired' },
      { tag: 'revoked', reason: 'server_revoked' },
    ];

    for (const state of states) {
      expect(toAuthView(state).canRenderProtectedShell).toBe(false);
    }
  });
});

Test matrix minimal:

TestExpected
no session bootstrapanonymous
valid session bootstrapauthenticated, not ready
permission loadedready
permission deniedforbidden
refresh successready with new session
refresh expiredexpired
refresh revokedrevoked
logout from readylogging_out -> anonymous
tenant switch successready with new tenant and permission snapshot
tenant switch deniedforbidden

29. Observability

State transitions harus bisa diamati.

Log event aman:

{
  "event": "auth_state_transition",
  "from": "ready",
  "to": "refreshing",
  "reason": "scheduled",
  "tenantId": "tnt_123",
  "sessionAgeMs": 1200000,
  "correlationId": "req_abc"
}

Jangan log:

  • access token;
  • refresh token;
  • authorization code;
  • full cookie;
  • raw ID token;
  • password;
  • MFA code;
  • secret recovery code.

Metrics yang berguna:

auth.bootstrap.success.count
auth.bootstrap.failure.count
auth.refresh.started.count
auth.refresh.success.count
auth.refresh.failure.count
auth.refresh.singleflight.waiters
auth.logout.started.count
auth.logout.completed.count
auth.forbidden.count
auth.revoked.count
auth.degraded.count
auth.redirect_loop.detected.count

State machine membuat metrics lebih berarti karena event vocabulary stabil.


30. Anti-pattern catalog

Anti-pattern: isAuthenticated dari localStorage

const isAuthenticated = Boolean(localStorage.getItem('token'));

Masalah:

  • token bisa expired;
  • token bisa revoked;
  • localStorage bisa stale;
  • permission tidak diketahui;
  • token existence bukan authorization.

Anti-pattern: redirect loop tanpa state

if (!user) navigate('/login');
if (user) navigate('/dashboard');

Tanpa state bootstrapping, handling_callback, dan anonymous, login/callback bisa saling redirect.

Anti-pattern: refresh di setiap request failure

if (status === 401) refresh();

Tanpa single-flight dan max replay, ini bisa menjadi refresh storm.

Anti-pattern: forbidden memaksa logout

if (status === 403) logout();

403 bukan bukti session invalid. Ia bukti access denied.

Anti-pattern: role boolean tersebar

const isAdmin = user.role === 'admin';

Ini tidak cukup untuk resource-specific, tenant-specific, state-specific permission.


31. Practical design checklist

Sebelum membangun auth provider, jawab pertanyaan ini:

1. Apa state awal aplikasi?
2. Apa yang terjadi saat session tidak ada?
3. Apa yang terjadi saat session expired?
4. Apa yang terjadi saat session revoked?
5. Apa perbedaan authenticated dan ready?
6. Kapan permission dimuat?
7. Apa policy saat permission unknown?
8. Apa policy saat refresh sedang berjalan?
9. Berapa kali request boleh direplay setelah refresh?
10. Apa yang terjadi jika user logout saat request masih berjalan?
11. Apa yang terjadi jika user membuka dua tab?
12. Apa yang terjadi jika tenant berubah?
13. Apa yang terjadi jika backend mengembalikan 403?
14. Apa yang terjadi jika IdP unavailable?
15. State transition mana yang diaudit?

Kalau tim tidak bisa menjawab ini, implementasi auth akan bergantung pada kebetulan.


32. Reference architecture kecil

Layer responsibility:

LayerResponsibility
ComponentRender based on view model/permission decision.
Auth view modelHide internal state complexity.
Auth storeHold state and dispatch event.
ReducerPure transition.
Effect runnerNetwork/storage/router/broadcast/cache side effects.
API clientEnforce request policy, refresh coordination.
RouterPrevent protected render before auth decision.
BackendReal enforcement boundary.

33. Final mental model

Auth state machine bukan overengineering. Ia adalah cara membuat sistem auth punya bentuk.

Tanpa state machine, auth menjadi kumpulan patch:

if token missing -> login
if 401 -> refresh
if refresh fail -> logout
if role admin -> show button
if callback -> set user

Dengan state machine, auth menjadi sistem:

state + event -> next state + controlled side effects

Untuk React apps kompleks, ini perbedaan antara auth yang “jalan di demo” dan auth yang bisa bertahan di production.


34. Ringkasan

Part ini membangun fondasi state machine untuk React auth:

  • jangan mulai dari boolean soup;
  • bedakan authenticated dan ready;
  • permission loading adalah state eksplisit;
  • refresh harus single-flight dan bounded replay;
  • logout adalah distributed cleanup flow;
  • 401 dan 403 punya arti berbeda;
  • tenant switch harus membersihkan context dan cache;
  • degraded state harus eksplisit;
  • route/data boundary harus memakai state machine;
  • transition harus bisa dites tanpa browser;
  • event vocabulary bisa dipakai untuk observability.

Part berikutnya membahas taxonomy kegagalan auth. Tujuannya: setiap failure punya nama, penyebab, recovery, UX, HTTP mapping, dan observability yang jelas.


References

Lesson Recap

You just completed lesson 07 in start here. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.