151 lines
4.6 KiB
TypeScript
151 lines
4.6 KiB
TypeScript
import type { Context, Next } from "jsr:@hono/hono@4";
|
|
import { getCookie } from "jsr:@hono/hono@4/cookie";
|
|
import type { WSContext, WSEvents } from "jsr:@hono/hono@4/ws";
|
|
import type { AuthSdk, InvalidationHandler } from "./mod.ts";
|
|
|
|
/**
|
|
* Universal Hono authentication middleware for Auth-Yes.
|
|
* Extracts session tokens from cookies or Authorization Bearer headers,
|
|
* validates against Auth-Yes, and injects 'userId' and 'scopes' into context.
|
|
*/
|
|
export function createAuthMiddleware(sdk: AuthSdk) {
|
|
return async (c: Context, next: Next) => {
|
|
// 1. Check Traefik ForwardAuth Ingress Headers first
|
|
const forwardedUserId = c.req.header("X-Forwarded-User-Id") ||
|
|
c.req.header("x-forwarded-user-id");
|
|
if (forwardedUserId) {
|
|
const rawScopes = c.req.header("X-Forwarded-Scopes") ||
|
|
c.req.header("x-forwarded-scopes") || "";
|
|
const scopes = rawScopes.split(",").map((s) => s.trim()).filter(Boolean);
|
|
c.set("userId", forwardedUserId);
|
|
c.set("scopes", scopes);
|
|
return await next();
|
|
}
|
|
|
|
let token = getCookie(c, "session_id");
|
|
|
|
if (!token) {
|
|
const authHeader = c.req.header("Authorization");
|
|
if (authHeader && authHeader.startsWith("Bearer ")) {
|
|
token = authHeader.substring("Bearer ".length);
|
|
}
|
|
}
|
|
|
|
if (!token) {
|
|
return c.json({ error: "Unauthorized: Missing session token" }, 401);
|
|
}
|
|
|
|
try {
|
|
const session = await sdk.validateSession(token);
|
|
if (!session.valid || !session.uuid) {
|
|
return c.json(
|
|
{ error: session.error || "Unauthorized: Invalid session" },
|
|
401,
|
|
);
|
|
}
|
|
|
|
c.set("userId", session.uuid);
|
|
c.set("scopes", session.scopes || []);
|
|
await next();
|
|
} catch (_e) {
|
|
return c.json({ error: "Unauthorized" }, 401);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* RBAC Scope Guard Middleware.
|
|
* Ensures the authenticated user possesses the required application scope (or global 'admin').
|
|
*/
|
|
export function requireScope(requiredScope: string) {
|
|
return async (c: Context, next: Next) => {
|
|
const scopes: string[] = c.get("scopes") || [];
|
|
if (!scopes.includes(requiredScope) && !scopes.includes("admin")) {
|
|
return c.json(
|
|
{ error: `Forbidden: Required scope '${requiredScope}' missing.` },
|
|
403,
|
|
);
|
|
}
|
|
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);
|
|
}
|
|
},
|
|
};
|
|
}
|