auth-yes/sdk/mod.ts

205 lines
5.8 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";
/**
* 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;
}
/**
* 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;
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);
}
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) => {
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);
}
}
}
});
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): Promise<SessionData> {
// Check L1 cache first
const cachedSession = this.l1Cache.get(token);
if (cachedSession) {
return cachedSession;
}
try {
const response = await this.grpcClient.validateSession({ token });
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);
}