auth-yes/sdk/hono.ts

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);
}
},
};
}