feat(sdk): Implement Ghost Cockpit Protocol WebSocket guard helper
- Formalized Ghost Cockpit Protocol in docs/GHOST_COCKPIT_SPEC.md. - Added `GhostCockpitClient` reference implementation. - Implemented `createWebSocketGuard` in `sdk/hono.ts` to seamlessly terminate invalidated user sessions with code 1008. - Added robust lifecycle cleanups and error checking for WebSocket frame deliveries on socket close. - Added comprehensive integration tests in `sdk/hono.test.ts`. - Cleaned unused imports and fixed all linting warnings. - Moved task definition to complete. Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
This commit is contained in:
parent
e15e5a8038
commit
b919c66cbc
143
docs/GHOST_COCKPIT_SPEC.md
Normal file
143
docs/GHOST_COCKPIT_SPEC.md
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
# 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:
|
||||||
|
```json
|
||||||
|
{ "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.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@ -1,6 +1,11 @@
|
|||||||
import { Hono } from "jsr:@hono/hono@4";
|
import { Hono } from "jsr:@hono/hono@4";
|
||||||
import { assertEquals } from "jsr:@std/assert";
|
import { assertEquals } from "jsr:@std/assert";
|
||||||
import { createAuthMiddleware, requireScope } from "./hono.ts";
|
import type { WSContext } from "jsr:@hono/hono@4/ws";
|
||||||
|
import {
|
||||||
|
createAuthMiddleware,
|
||||||
|
createWebSocketGuard,
|
||||||
|
requireScope,
|
||||||
|
} from "./hono.ts";
|
||||||
import { stub } from "jsr:@std/testing/mock";
|
import { stub } from "jsr:@std/testing/mock";
|
||||||
import { AuthSdk } from "./mod.ts";
|
import { AuthSdk } from "./mod.ts";
|
||||||
|
|
||||||
@ -138,3 +143,74 @@ Deno.test("requireScope - rejects user when scope is missing", async () => {
|
|||||||
error: "Forbidden: Required scope 'commander' missing.",
|
error: "Forbidden: Required scope 'commander' missing.",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("createWebSocketGuard - closes immediately if no token is provided", () => {
|
||||||
|
const sdk = new AuthSdk({ authApiUrl: "http://localhost" });
|
||||||
|
const guard = createWebSocketGuard(sdk);
|
||||||
|
|
||||||
|
let sentData: string | undefined;
|
||||||
|
let closeCode: number | undefined;
|
||||||
|
let closeReason: string | undefined;
|
||||||
|
|
||||||
|
const mockWs = {
|
||||||
|
send: (data: string) => {
|
||||||
|
sentData = data;
|
||||||
|
},
|
||||||
|
close: (code: number, reason: string) => {
|
||||||
|
closeCode = code;
|
||||||
|
closeReason = reason;
|
||||||
|
},
|
||||||
|
} as unknown as WSContext;
|
||||||
|
|
||||||
|
guard.onOpen!(new Event("open"), mockWs);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
sentData,
|
||||||
|
JSON.stringify({ type: "AUTH_REVOKED", reason: "SESSION_EXPIRED" }),
|
||||||
|
);
|
||||||
|
assertEquals(closeCode, 1008);
|
||||||
|
assertEquals(closeReason, "Session Expired");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("createWebSocketGuard - handles invalidate event and cleans up on close", () => {
|
||||||
|
const sdk = new AuthSdk({ authApiUrl: "http://localhost" });
|
||||||
|
const token = "test-token-123";
|
||||||
|
const guard = createWebSocketGuard(sdk, token);
|
||||||
|
|
||||||
|
let sentData: string | undefined;
|
||||||
|
let closeCode: number | undefined;
|
||||||
|
let closeReason: string | undefined;
|
||||||
|
|
||||||
|
const mockWs = {
|
||||||
|
send: (data: string) => {
|
||||||
|
sentData = data;
|
||||||
|
},
|
||||||
|
close: (code: number, reason: string) => {
|
||||||
|
closeCode = code;
|
||||||
|
closeReason = reason;
|
||||||
|
},
|
||||||
|
} as unknown as WSContext;
|
||||||
|
|
||||||
|
guard.onOpen!(new Event("open"), mockWs);
|
||||||
|
|
||||||
|
// Trigger invalidation for a different token (should do nothing)
|
||||||
|
sdk["emit"]("invalidate", "other-token");
|
||||||
|
assertEquals(sentData, undefined);
|
||||||
|
assertEquals(closeCode, undefined);
|
||||||
|
|
||||||
|
// Trigger invalidation for the matching token
|
||||||
|
sdk["emit"]("invalidate", token);
|
||||||
|
assertEquals(
|
||||||
|
sentData,
|
||||||
|
JSON.stringify({ type: "AUTH_REVOKED", reason: "SESSION_EXPIRED" }),
|
||||||
|
);
|
||||||
|
assertEquals(closeCode, 1008);
|
||||||
|
assertEquals(closeReason, "Session Expired");
|
||||||
|
|
||||||
|
// Verify listener is registered
|
||||||
|
assertEquals(sdk["listeners"].get("invalidate")?.size, 1);
|
||||||
|
|
||||||
|
// Verify listener cleanup on close
|
||||||
|
guard.onClose!(new CloseEvent("close"), mockWs);
|
||||||
|
assertEquals(sdk["listeners"].has("invalidate"), false);
|
||||||
|
});
|
||||||
|
|||||||
82
sdk/hono.ts
82
sdk/hono.ts
@ -1,6 +1,7 @@
|
|||||||
import type { Context, Next } from "jsr:@hono/hono@4";
|
import type { Context, Next } from "jsr:@hono/hono@4";
|
||||||
import { getCookie } from "jsr:@hono/hono@4/cookie";
|
import { getCookie } from "jsr:@hono/hono@4/cookie";
|
||||||
import type { AuthSdk } from "./mod.ts";
|
import type { WSContext, WSEvents } from "jsr:@hono/hono@4/ws";
|
||||||
|
import type { AuthSdk, InvalidationHandler } from "./mod.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Universal Hono authentication middleware for Auth-Yes.
|
* Universal Hono authentication middleware for Auth-Yes.
|
||||||
@ -56,3 +57,82 @@ export function requireScope(requiredScope: string) {
|
|||||||
await next();
|
await next();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a WebSocket session guard that integrates with the Ghost Cockpit Protocol.
|
||||||
|
* This guard listens for `invalidate` events from the AuthSdk. If the monitored
|
||||||
|
* session is invalidated, it sends an `AUTH_REVOKED` frame and closes the socket
|
||||||
|
* with a 1008 policy violation.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { upgradeWebSocket } from "jsr:@hono/hono/deno";
|
||||||
|
* import { getCookie } from "jsr:@hono/hono/cookie";
|
||||||
|
*
|
||||||
|
* app.get("/ws", upgradeWebSocket((c) => {
|
||||||
|
* const token = getCookie(c, "session_id");
|
||||||
|
* const guard = createWebSocketGuard(authSdk, token);
|
||||||
|
* return {
|
||||||
|
* ...guard,
|
||||||
|
* onMessage(event, ws) {
|
||||||
|
* // Handle regular application messages here
|
||||||
|
* },
|
||||||
|
* };
|
||||||
|
* }));
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param sdk The `AuthSdk` instance.
|
||||||
|
* @param token The session token to monitor. If undefined, the guard will immediately close the socket upon connection.
|
||||||
|
* @returns A partial `WSEvents` object containing `onOpen`, `onClose`, and `onError` handlers.
|
||||||
|
*/
|
||||||
|
export function createWebSocketGuard(
|
||||||
|
sdk: AuthSdk,
|
||||||
|
token?: string,
|
||||||
|
): Partial<WSEvents> {
|
||||||
|
let invalidationHandler: InvalidationHandler | undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
onOpen(_event: Event, ws: WSContext) {
|
||||||
|
if (!token) {
|
||||||
|
// No session token provided, close immediately
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({ type: "AUTH_REVOKED", reason: "SESSION_EXPIRED" }),
|
||||||
|
);
|
||||||
|
ws.close(1008, "Session Expired");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidationHandler = (invalidatedToken: string) => {
|
||||||
|
if (invalidatedToken === token) {
|
||||||
|
try {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "AUTH_REVOKED",
|
||||||
|
reason: "SESSION_EXPIRED",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (_e) {
|
||||||
|
// Ignore send errors if socket is already closing/closed
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ws.close(1008, "Session Expired");
|
||||||
|
} catch (_e) {
|
||||||
|
// Ignore close errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sdk.on("invalidate", invalidationHandler);
|
||||||
|
},
|
||||||
|
onClose(_event: CloseEvent, _ws: WSContext) {
|
||||||
|
if (invalidationHandler) {
|
||||||
|
sdk.off("invalidate", invalidationHandler);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError(_event: Event, _ws: WSContext) {
|
||||||
|
if (invalidationHandler) {
|
||||||
|
sdk.off("invalidate", invalidationHandler);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user