auth-yes/docs/GHOST_COCKPIT_SPEC.md

5.7 KiB

Ghost Cockpit Protocol Specification

Overview

The Ghost Cockpit Protocol provides a seamless experience for real-time applications connected via WebSockets to Auth-Yes secured services. When a user's session expires or is revoked, instead of abruptly disconnecting and redirecting the user to a login page (losing unsaved state or telemetry data), the application enters a "frozen" or "ghost" state.

In this state:

  1. The WebSocket connection is gracefully terminated by the server with a specific control frame and status code.
  2. The UI freezes real-time updates and input, keeping the current state intact.
  3. A background or modal re-authentication process (like WebAuthn biometric prompt) is triggered.
  4. Upon successful re-authentication, the WebSocket connection is re-established, and the application resumes normal operation without any page reload.

Choreography

  1. Active Connection: The client maintains an active WebSocket connection. The backend uses the createWebSocketGuard to monitor the session.
  2. Session Invalidation: The Auth-Yes API Gateway or an admin invalidates the session. An invalidate event is propagated through the AuthSdk.
  3. Revocation Frame: The WebSocket Guard intercepts the event and sends a JSON control frame:
    { "type": "AUTH_REVOKED", "reason": "SESSION_EXPIRED" }
    
  4. Connection Close: Immediately after sending the frame, the server closes the WebSocket with status code 1008 (Policy Violation) and reason "Session Expired".
  5. Client Freeze: The client application receives the AUTH_REVOKED frame (or detects the 1008 close code) and transitions to the "Ghost Cockpit" state.
  6. Re-authentication: The client calls Auth-Yes endpoints (e.g., /api/login/challenge and /api/login/verify) using the Web Authentication API (navigator.credentials.get()).
  7. Resumption: Once a new session token is obtained, the client reconnects the WebSocket and the UI unfreezes.

Reference Client Implementation

Below is a complete, reference client-side TypeScript snippet demonstrating the GhostCockpitClient class that manages this lifecycle.

/**
 * GhostCockpitClient manages a resilient WebSocket connection that supports
 * the Ghost Cockpit Protocol for non-destructive re-authentication.
 */
export class GhostCockpitClient {
  private ws: WebSocket | null = null;
  private url: string;
  private isFrozen: boolean = false;

  // Application callbacks
  public onMessage?: (data: any) => void;
  public onStateChange?: (frozen: boolean) => void;

  constructor(url: string) {
    this.url = url;
  }

  /**
   * Connects to the WebSocket server.
   */
  public connect(): void {
    this.ws = new WebSocket(this.url);

    this.ws.onopen = () => {
      console.log("[GhostCockpit] Connected.");
      if (this.isFrozen) {
        this.setFrozen(false);
      }
    };

    this.ws.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data);
        if (data.type === "AUTH_REVOKED" && data.reason === "SESSION_EXPIRED") {
          console.warn("[GhostCockpit] Received AUTH_REVOKED frame.");
          this.handleSessionExpired();
          return;
        }
      } catch (e) {
        // Not JSON, normal message
      }

      if (this.onMessage && !this.isFrozen) {
        this.onMessage(event.data);
      }
    };

    this.ws.onclose = (event) => {
      console.log(`[GhostCockpit] Disconnected (code: ${event.code}).`);
      if (event.code === 1008 && event.reason === "Session Expired") {
        this.handleSessionExpired();
      } else if (!this.isFrozen) {
        // Attempt normal reconnection logic here (omitted for brevity)
        setTimeout(() => this.connect(), 5000);
      }
    };
  }

  /**
   * Updates the frozen state and triggers the UI callback.
   */
  private setFrozen(frozen: boolean) {
    this.isFrozen = frozen;
    if (this.onStateChange) {
      this.onStateChange(frozen);
    }
  }

  /**
   * Triggers the freeze state and initiates background WebAuthn re-auth.
   */
  private async handleSessionExpired() {
    if (this.isFrozen) return; // Already handling
    this.setFrozen(true);
    console.log(
      "[GhostCockpit] UI Frozen. Initiating background re-authentication...",
    );

    try {
      // 1. Fetch WebAuthn Challenge from Auth-Yes
      const challengeRes = await fetch("/api/login/challenge", {
        method: "POST",
      });
      if (!challengeRes.ok) throw new Error("Failed to get challenge");
      const challengeData = await challengeRes.json();

      // Convert server challenge/ids to Uint8Array (omitted util details)
      // e.g., decode base64url to Uint8Array

      // 2. Prompt user for Biometrics / Security Key via Web Authentication API
      const credential = await navigator.credentials.get({
        publicKey: challengeData.publicKeyRequestOptions,
      });
      if (!credential) throw new Error("Credential not provided");

      // 3. Verify credential with Auth-Yes
      // (Serialize credential response before sending)
      const verifyRes = await fetch("/api/login/verify", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ credential }), // Mock serialization
      });

      if (!verifyRes.ok) throw new Error("Re-authentication failed");

      console.log(
        "[GhostCockpit] Re-authentication successful! Reconnecting...",
      );
      // Reconnect with new session (implicitly via browser cookies or explicit headers)
      this.connect();
    } catch (err) {
      console.error("[GhostCockpit] Re-authentication error:", err);
      // Fallback: Redirect to full login page or show permanent error
      window.location.href = "/login";
    }
  }
}