// 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; /** * 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; private valkeyClient: Redis | null = null; private grpcClient: any; private listeners: Map> = new Map(); constructor(config: AuthSdkConfig) { this.config = config; this.l1Cache = new Map(); if (this.config.valkeyUrl) { this.initValkeyClient(); } const nodeOptions: Record = { 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 { // 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 { 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); }