User Authentication Example

User authentication isn’t a single action—it’s a series of states your application moves through. The user starts logged out. They enter credentials and your app transitions to a validating state. If validation succeeds, you move to logged in. If the session expires, you transition to refreshing. Each phase has its own behavior, its own valid actions, its own potential outcomes.

This example demonstrates error handling, validation flows, and session management through a finite state machine. Unlike the simpler light bulb or traffic light, authentication involves multiple decision points, error states, and recovery paths.

Try It Now

Enter your credentials below to experience the authentication flow. The session expires after 30 seconds and automatically refreshes. Open your browser console to see state transitions and events in real-time.

Demo Credentials: username=demo, password=demo123

All code shown in this guide is fully functional and organized into working files you can explore:

user-authentication/
├── user-authentication.impl/    # Infrastructure fragment
│   ├── index.ts                 # Entry point with init handler
│   ├── auth-api.ts              # Simulated auth API
│   └── context.ts               # Context initialization
├── user-authentication.core/    # Core fragment (platform-independent)
│   ├── index.ts                 # Entry point
│   ├── process.ts               # FSM configuration
│   ├── auth-api.interface.ts    # Auth API interface
│   ├── models.ts                # Reactive models + adapters
│   ├── controllers.ts           # State controllers
│   └── triggers.ts              # Event trigger
├── user-authentication.views/   # View fragment (browser-specific)
│   ├── index.ts                 # Entry point
│   ├── render-auth-form.ts      # UI rendering
│   └── views.ts                 # View handlers
├── user-authentication.test/    # Test suite
│   ├── mocks/                   # Mock implementations
│   ├── core/                    # Unit tests
│   └── integration/             # Integration tests
└── index.html                   # Standalone browser demo

The State Machine

The FSM definition lives in user-authentication.core/process.ts. It models the complete authentication lifecycle with seven states and multiple decision points:

export const config = {
  key: "UserAuthentication",
  transitions: [
    // Initial state
    ["", "*", "LoggedOut"],

    // Login flow
    ["LoggedOut", "attemptLogin", "ValidatingCredentials"],
    ["ValidatingCredentials", "credentialsValid", "CreatingSession"],
    ["ValidatingCredentials", "credentialsInvalid", "ShowingError"],

    // Session creation
    ["CreatingSession", "sessionCreated", "LoggedIn"],
    ["CreatingSession", "sessionFailed", "ShowingError"],

    // Active session
    ["LoggedIn", "logout", "LoggingOut"],
    ["LoggedIn", "sessionExpiring", "RefreshingSession"],

    // Session refresh
    ["RefreshingSession", "refreshSuccess", "LoggedIn"],
    ["RefreshingSession", "refreshFailed", "LoggedOut"],

    // Logout flow
    ["LoggingOut", "logoutComplete", "LoggedOut"],

    // Error handling
    ["ShowingError", "retry", "LoggedOut"],
    ["ShowingError", "cancel", "LoggedOut"],

    // Global error handling
    ["*", "networkError", "ShowingError"],
    ["*", "forceLogout", ""]
  ],
  states: [
    {
      key: "LoggedOut",
      description: "User not authenticated",
      events: ["attemptLogin", "networkError", "forceLogout"]
    },
    {
      key: "ValidatingCredentials",
      description: "Checking username and password",
      events: ["credentialsValid", "credentialsInvalid", "networkError", "forceLogout"]
    },
    {
      key: "CreatingSession",
      description: "Establishing user session",
      events: ["sessionCreated", "sessionFailed", "networkError", "forceLogout"]
    },
    {
      key: "LoggedIn",
      description: "User authenticated with active session",
      events: ["logout", "sessionExpiring", "networkError", "forceLogout"]
    },
    {
      key: "RefreshingSession",
      description: "Renewing authentication token",
      events: ["refreshSuccess", "refreshFailed", "networkError", "forceLogout"]
    },
    {
      key: "LoggingOut",
      description: "Cleaning up user session",
      events: ["logoutComplete", "networkError", "forceLogout"]
    },
    {
      key: "ShowingError",
      description: "Displaying error message to user",
      events: ["retry", "cancel", "forceLogout"]
    }
  ]
};

The primary path is LoggedOut → ValidatingCredentials → CreatingSession → LoggedIn. Error paths branch to ShowingError with options to retry. Session management adds RefreshingSession for automatic token renewal. Logout follows its own path through LoggingOut back to LoggedOut.

Understanding Decision Points

Authentication has multiple decision points—places where the outcome determines the next state. After validating credentials, you either transition to CreatingSession (success) or ShowingError (failure). These branching paths model real-world outcomes.

The FSM makes these decisions explicit. Traditional code might have nested if statements scattered across functions. The state machine puts all decision points in one place—the transitions list. You see every possible path through the authentication process.

Reactive Models

The application uses reactive models that automatically notify listeners when data changes. The main model is defined in user-authentication.core/models.ts:

export class UserAuthModel extends BaseClass {
  #username: string | null = null;
  #token: string | null = null;
  #tokenExpiry: number | null = null;
  #error: string | null = null;
  #isLoading = false;
  #loginAttempts = 0;

  // Custom subscriptions for FSM events
  onCredentialsSubmit = (callback: () => void) => { /* ... */ };
  onSessionRefresh = (callback: () => void) => { /* ... */ };
  onLogout = (callback: () => void) => { /* ... */ };

  // Signal methods that trigger FSM events
  signalCredentialsSubmit() { /* ... */ }
  signalSessionRefresh() { /* ... */ }
  signalLogout() { /* ... */ }

  // Business methods
  setSession(token: string, expiresIn: number) { /* ... */ }
  clearSession() { /* ... */ }
  setError(error: string) { /* ... */ }
  clearError() { /* ... */ }
}

The model also includes a separate CredentialsModel for handling username and password. This separation is intentional—credentials are transient (exist only during login) while session state is persistent. Separating them makes security clearer and testing easier.

Controllers: Business Logic

Controllers implement state-specific behavior in user-authentication.core/controllers.ts. Each state has its own controller that runs when the state activates.

For example, the validation controller handles checking credentials:

export async function* ValidatingCredentialsController(
  context: Record<string, unknown>
): AsyncGenerator<string> {
  const authModel = getUserAuthModel(context);
  const credentialsModel = getCredentialsModel(context);
  const authApi = getAuthApi(context);

  authModel.setLoading(true);
  authModel.incrementLoginAttempts();

  try {
    const result = await authApi.validateCredentials(
      credentialsModel.username,
      credentialsModel.password
    );

    authModel.setLoading(false);

    if (result.valid) {
      authModel.setUsername(credentialsModel.username);
      yield "credentialsValid";
    } else {
      authModel.setError(result.error || "Invalid credentials");
      yield "credentialsInvalid";
    }
  } catch (error) {
    authModel.setLoading(false);
    authModel.setError(`Network error: ${error.message}`);
    yield "networkError";
  } finally {
    // Clear password from memory immediately after validation
    credentialsModel.clear();
  }
}

Controllers that perform async operations (like API calls) use async generators. They yield FSM events based on the operation result. Controllers that only set up state (like LoggedInController) return cleanup functions instead.

The LoggedInController demonstrates timer-based events:

export async function LoggedInController(
  context: Record<string, unknown>
): Promise<() => void> {
  const [register, cleanup] = newRegistry();
  const authModel = getUserAuthModel(context);

  // Calculate time until expiry warning
  const timeUntilExpiry = (authModel.tokenExpiry || 0) - Date.now();
  const warningTime = timeUntilExpiry - 5000; // Warn 5 seconds before expiry

  if (warningTime > 0) {
    // Controller starts timer; callback updates model when timer completes
    const timerId = setTimeout(() => {
      authModel.signalSessionRefresh(); // Timer callback updates model
    }, warningTime);

    register(() => clearTimeout(timerId));
  }

  return cleanup;
}

Notice the pattern: the controller starts the timer, and when the timer fires, it updates the model. The trigger (described next) observes the model change and generates the FSM event.

Triggers: Event Generation

Triggers are read-only observers that generate FSM events based on model changes. The root trigger is defined in user-authentication.core/triggers.ts:

export function UserAuthenticationTrigger(
  context: Record<string, unknown>
): AsyncGenerator<string> {
  const model = getUserAuthModel(context);

  return newAsyncGenerator<string>((next, done) => {
    // Subscribe to credentials submission
    const credentialsCleanup = model.onCredentialsSubmit(() => {
      next("attemptLogin");
    });

    // Subscribe to session refresh trigger
    const refreshCleanup = model.onSessionRefresh(() => {
      next("sessionExpiring");
    });

    // Subscribe to logout trigger
    const logoutCleanup = model.onLogout(() => {
      next("logout");
    });

    // Return cleanup function to unsubscribe when generator closes
    return () => {
      credentialsCleanup();
      refreshCleanup();
      logoutCleanup();
    };
  });
}

Triggers never modify models, never call APIs, and never access the DOM. They only observe model changes and generate events. This separation keeps the data flow unidirectional and predictable.

Views: User Interface

Views handle the user interface in user-authentication.views/views.ts. The main view creates the login form and wires up user interactions:

export function UserAuthenticationView(
  context: Record<string, unknown>
): () => void {
  const [register, cleanup] = newRegistry();
  const authModel = getUserAuthModel(context);
  const credentialsModel = getCredentialsModel(context);

  const element = renderAuthForm();
  document.querySelector("#user-auth-demo")!.appendChild(element);
  register(() => element.remove());

  // Wire up login form submission
  const loginForm = element.querySelector(".login-form") as HTMLFormElement;
  const handleSubmit = (e: Event) => {
    e.preventDefault();
    const username = (element.querySelector("#username") as HTMLInputElement).value;
    const password = (element.querySelector("#password") as HTMLInputElement).value;

    // ✅ CORRECT: Capture user input and signal user action
    credentialsModel.setCredentials(username, password);
    authModel.signalCredentialsSubmit();
  };

  loginForm.addEventListener("submit", handleSubmit);
  register(() => loginForm.removeEventListener("submit", handleSubmit));

  return cleanup;
}

Views follow strict rules about what they can write:

Controllers handle all business logic. Views only capture user actions and signal them to the model.

Infrastructure: API Implementation

The infrastructure layer lives in user-authentication.impl. It provides the actual API implementation that the core layer depends on:

export class AuthApiImpl implements IAuthApi {
  async validateCredentials(username: string, password: string) {
    await this.sleep(1000); // Simulate network delay

    if (username === "demo" && password === "demo123") {
      return { valid: true };
    }
    return { valid: false, error: "Invalid username or password" };
  }

  async createSession(username: string) {
    await this.sleep(500);
    const token = `token_${Date.now()}_${Math.random().toString(36).substring(7)}`;
    return { token, expiresIn: 30000 }; // 30 seconds for demo
  }

  async refreshToken(token: string) {
    await this.sleep(500);
    // Simulate 80% success rate for demo purposes
    if (Math.random() > 0.2) {
      const newToken = `token_${Date.now()}_${Math.random().toString(36).substring(7)}`;
      return { token: newToken, expiresIn: 30000 };
    }
    throw new Error("Token refresh failed - session expired");
  }

  async logout(token: string) {
    await this.sleep(300);
    return { success: true };
  }
}

The impl fragment uses the adapter pattern to inject dependencies. The core layer defines interfaces, and the impl layer provides concrete implementations. This makes testing easy—tests can provide mock implementations instead of real APIs.

Testing

The example includes comprehensive tests in user-authentication.test:

The integration test demonstrates the complete flow:

it("should complete successful login flow", async () => {
  // Simulate user entering credentials
  credentialsModel.setCredentials("demo", "demo123");
  authModel.signalCredentialsSubmit();

  // Wait for token to be set (indicates successful login)
  await waitForTruthy(authModel, "token", 5000);

  // Verify final state
  expect(authModel.token).toBeTruthy();
  expect(authModel.username).toBe("demo");
  expect(authModel.error).toBeNull();
});

Tests use a mock API from mocks/auth-api.mock.ts that provides deterministic, synchronous responses. This makes tests fast and reliable.

Key Patterns

This example demonstrates several important patterns:

  1. Three-fragment architecture - Separation of infrastructure, business logic, and presentation
  2. Reactive models - Automatic UI updates when data changes
  3. Async generators for controllers - Clean handling of async operations that yield FSM events
  4. Read-only triggers - Triggers observe models but never modify them
  5. Adapter pattern - Dependencies injected through context, easy to mock for testing
  6. Unidirectional data flow - User action → Model → Controller → Model → View → Trigger → FSM Event

What Makes This Different

Traditional authentication code might scatter validation logic across multiple files. Session management might be implicit in middleware. Error handling might use try-catch blocks that obscure the actual flow.

The FSM approach makes everything explicit. You see all possible states. You see all possible transitions. You see exactly what triggers each transition. The code maps directly to the state diagram.

When debugging, you know exactly which state you’re in. When adding features, you know exactly where to add new transitions. When writing tests, you can verify specific state transitions without worrying about hidden state mutations.

Source Code

All source code for this example is available:

The complete implementation demonstrates production-ready patterns for managing complex state machines in TypeScript applications.