5.7 KiB
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:
- The WebSocket connection is gracefully terminated by the server with a specific control frame and status code.
- The UI freezes real-time updates and input, keeping the current state intact.
- A background or modal re-authentication process (like WebAuthn biometric prompt) is triggered.
- Upon successful re-authentication, the WebSocket connection is re-established, and the application resumes normal operation without any page reload.
Choreography
- Active Connection: The client maintains an active WebSocket connection.
The backend uses the
createWebSocketGuardto monitor the session. - Session Invalidation: The Auth-Yes API Gateway or an admin invalidates
the session. An
invalidateevent is propagated through theAuthSdk. - Revocation Frame: The WebSocket Guard intercepts the event and sends a
JSON control frame:
{ "type": "AUTH_REVOKED", "reason": "SESSION_EXPIRED" } - Connection Close: Immediately after sending the frame, the server closes the WebSocket with status code 1008 (Policy Violation) and reason "Session Expired".
- Client Freeze: The client application receives the
AUTH_REVOKEDframe (or detects the 1008 close code) and transitions to the "Ghost Cockpit" state. - Re-authentication: The client calls Auth-Yes endpoints (e.g.,
/api/login/challengeand/api/login/verify) using the Web Authentication API (navigator.credentials.get()). - 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";
}
}
}