Added a lightweight, zero-dependency event bus to the `AuthSdk` to listen for Valkey RESP3 push invalidation events and emit them to registered listeners. The implementation properly captures errors from synchronous and asynchronous listeners, ensuring they do not crash the primary SDK listener loop. Extracted the Valkey event processor to improve hermetic testability. Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
288 lines
8.2 KiB
TypeScript
288 lines
8.2 KiB
TypeScript
// SDK Client for Auth-Yes Zero Trust Identity Provider
|
|
// Designed to be imported by subsidiary applications to validate stateful session tokens.
|
|
|
|
import { Redis } from "npm:ioredis";
|
|
import { createClient } from "npm:@connectrpc/connect@^1.4.0";
|
|
import { createConnectTransport } from "npm:@connectrpc/connect-node@^1.4.0";
|
|
import { AuthService } from "./gen/auth_connect.ts";
|
|
|
|
/**
|
|
* Supported event types for the Auth SDK.
|
|
*/
|
|
export type AuthSdkEvent = "invalidate";
|
|
|
|
/**
|
|
* Handler type for invalidation events.
|
|
*/
|
|
export type InvalidationHandler = (token: string) => void | Promise<void>;
|
|
|
|
/**
|
|
* Configuration options for the Auth SDK.
|
|
*/
|
|
export interface AuthSdkConfig {
|
|
/**
|
|
* The internal network URL of the Auth API Gateway.
|
|
* e.g., 'http://auth-api.internal:8000'
|
|
*/
|
|
authApiUrl: string;
|
|
/**
|
|
* The Valkey URL for RESP3 Client-Side Caching (optional).
|
|
* e.g., 'redis://auth-valkey:6379'
|
|
*/
|
|
valkeyUrl?: string;
|
|
/**
|
|
* Optional custom transport if deploying in environments (like browser)
|
|
* where connect-node is unavailable.
|
|
*/
|
|
customTransport?: any;
|
|
/**
|
|
* The mTLS certificate.
|
|
*/
|
|
tlsCert?: string;
|
|
/**
|
|
* The mTLS private key.
|
|
*/
|
|
tlsKey?: string;
|
|
/**
|
|
* The mTLS CA certificate.
|
|
*/
|
|
tlsCa?: string;
|
|
/**
|
|
* Network timeout for ConnectRPC calls in milliseconds (default: 5000ms).
|
|
*/
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
/**
|
|
* The validated session data returned by the Auth API.
|
|
*/
|
|
export interface SessionData {
|
|
valid: boolean;
|
|
uuid?: string;
|
|
scopes?: string[];
|
|
error?: string;
|
|
}
|
|
|
|
export class AuthSdk {
|
|
private config: AuthSdkConfig;
|
|
private l1Cache: Map<string, SessionData>;
|
|
private valkeyClient: Redis | null = null;
|
|
private grpcClient: any;
|
|
private listeners: Map<string, Set<InvalidationHandler>> = new Map();
|
|
|
|
constructor(config: AuthSdkConfig) {
|
|
this.config = config;
|
|
this.l1Cache = new Map();
|
|
|
|
if (this.config.valkeyUrl) {
|
|
this.initValkeyClient();
|
|
}
|
|
|
|
const nodeOptions: Record<string, any> = { rejectUnauthorized: false };
|
|
if (this.config.tlsCert && this.config.tlsKey && this.config.tlsCa) {
|
|
nodeOptions.rejectUnauthorized = true;
|
|
nodeOptions.cert = this.config.tlsCert;
|
|
nodeOptions.key = this.config.tlsKey;
|
|
nodeOptions.ca = this.config.tlsCa;
|
|
}
|
|
|
|
const transport = this.config.customTransport || createConnectTransport({
|
|
baseUrl: this.config.authApiUrl,
|
|
httpVersion: "2",
|
|
nodeOptions: nodeOptions,
|
|
});
|
|
|
|
this.grpcClient = createClient(AuthService, transport);
|
|
}
|
|
|
|
/**
|
|
* Registers a callback to be invoked when a specific event occurs.
|
|
*
|
|
* @param event The event name (e.g., "invalidate").
|
|
* @param handler The callback function.
|
|
*/
|
|
on(event: AuthSdkEvent, handler: InvalidationHandler): void {
|
|
let eventListeners = this.listeners.get(event);
|
|
if (!eventListeners) {
|
|
eventListeners = new Set();
|
|
this.listeners.set(event, eventListeners);
|
|
}
|
|
eventListeners.add(handler);
|
|
}
|
|
|
|
/**
|
|
* Unregisters a previously registered callback.
|
|
*
|
|
* @param event The event name (e.g., "invalidate").
|
|
* @param handler The callback function to remove.
|
|
*/
|
|
off(event: AuthSdkEvent, handler: InvalidationHandler): void {
|
|
const eventListeners = this.listeners.get(event);
|
|
if (eventListeners) {
|
|
eventListeners.delete(handler);
|
|
if (eventListeners.size === 0) {
|
|
this.listeners.delete(event);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Internal method to emit an event safely to all registered listeners.
|
|
*/
|
|
private emit(event: AuthSdkEvent, token: string): void {
|
|
const eventListeners = this.listeners.get(event);
|
|
if (eventListeners) {
|
|
for (const handler of eventListeners) {
|
|
try {
|
|
const result = handler(token);
|
|
if (result instanceof Promise) {
|
|
result.catch((err) => {
|
|
console.error("[AuthSdk] Error in 'invalidate' listener:", err);
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error("[AuthSdk] Error in 'invalidate' listener:", err);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Internal handler for RESP3 push invalidation messages.
|
|
* Exposed internally/to tests via the Valkey event listener.
|
|
*/
|
|
_handleValkeyPush(msg: unknown): void {
|
|
if (
|
|
Array.isArray(msg) && msg.length >= 2 && msg[0] === "invalidate"
|
|
) {
|
|
const keysToInvalidate = msg[1];
|
|
if (Array.isArray(keysToInvalidate)) {
|
|
for (const key of keysToInvalidate) {
|
|
// SIDE EFFECT: Delete the invalidated key from the local Map
|
|
this.l1Cache.delete(key);
|
|
// Emit invalidate event for real-time consumers
|
|
this.emit("invalidate", key);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private initValkeyClient() {
|
|
this.valkeyClient = new Redis(this.config.valkeyUrl!, {
|
|
enableOfflineQueue: false,
|
|
});
|
|
|
|
this.valkeyClient.on("ready", async () => {
|
|
// Negotiate RESP3 and enable client tracking
|
|
try {
|
|
await this.valkeyClient!.hello(3);
|
|
// Enable tracking in BCAST (broadcast) mode because this client
|
|
// never actually issues GET commands to trigger standard tracking
|
|
await this.valkeyClient!.client("TRACKING", "ON", "BCAST");
|
|
} catch (e) {
|
|
console.error("Failed to enable RESP3 client tracking:", e);
|
|
}
|
|
});
|
|
|
|
// Listen for RESP3 push invalidation messages
|
|
this.valkeyClient.on("push", (msg: unknown) => this._handleValkeyPush(msg));
|
|
|
|
this.valkeyClient.on("error", (err: unknown) => {
|
|
console.error("Valkey SDK Client error:", err);
|
|
console.warn(
|
|
"[AuthSdk] Valkey connection lost. Clearing L1 cache to prevent stale sessions.",
|
|
);
|
|
this.l1Cache.clear();
|
|
});
|
|
|
|
this.valkeyClient.on("close", () => {
|
|
console.warn(
|
|
"[AuthSdk] Valkey connection lost. Clearing L1 cache to prevent stale sessions.",
|
|
);
|
|
this.l1Cache.clear();
|
|
});
|
|
|
|
this.valkeyClient.on("end", () => {
|
|
console.warn(
|
|
"[AuthSdk] Valkey connection lost. Clearing L1 cache to prevent stale sessions.",
|
|
);
|
|
this.l1Cache.clear();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Validates an opaque session token against the Auth API.
|
|
* This is a fast-path operation that leverages the central Valkey cache.
|
|
*
|
|
* @param token The opaque session token (e.g., extracted from a cookie).
|
|
* @returns The validated session data containing the UUID and scopes.
|
|
*/
|
|
async validateSession(
|
|
token: string,
|
|
options?: { signal?: AbortSignal },
|
|
): Promise<SessionData> {
|
|
// Check L1 cache first
|
|
const cachedSession = this.l1Cache.get(token);
|
|
if (cachedSession) {
|
|
return cachedSession;
|
|
}
|
|
|
|
try {
|
|
const timeout = this.config.timeoutMs || 5000;
|
|
const signal = options?.signal || AbortSignal.timeout(timeout);
|
|
const response = await this.grpcClient.validateSession(
|
|
{ token },
|
|
{ signal },
|
|
);
|
|
|
|
const sessionData: SessionData = {
|
|
valid: response.valid,
|
|
uuid: response.uuid,
|
|
scopes: response.scopes || [],
|
|
error: response.error,
|
|
};
|
|
|
|
// Only populate L1 cache if Valkey integration is enabled for invalidations
|
|
if (sessionData.valid && this.config.valkeyUrl) {
|
|
this.l1Cache.set(token, sessionData);
|
|
}
|
|
|
|
return sessionData;
|
|
} catch (error) {
|
|
// Typically network errors or internal DNS resolution failures
|
|
return {
|
|
valid: false,
|
|
error: error instanceof Error ? error.message : "Unknown error",
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Middleware for web frameworks (e.g., Oak, Hono) to intercept and validate requests.
|
|
* Developers should wrap this around protected routes.
|
|
*
|
|
* @param token The extracted session token.
|
|
* @throws Error if the token is invalid or missing.
|
|
* @returns The user's UUID.
|
|
*/
|
|
async requireAuth(token: string | null | undefined): Promise<string> {
|
|
if (!token) {
|
|
throw new Error("Unauthorized: Missing session token.");
|
|
}
|
|
|
|
const session = await this.validateSession(token);
|
|
if (!session.valid || !session.uuid) {
|
|
throw new Error(`Unauthorized: ${session.error || "Invalid session."}`);
|
|
}
|
|
|
|
return session.uuid;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a new instance of the Auth SDK.
|
|
*/
|
|
export function createAuthSdk(config: AuthSdkConfig): AuthSdk {
|
|
return new AuthSdk(config);
|
|
}
|