UNPKG

@juspay/neurolink

Version:

Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio

724 lines 26.3 kB
/** * BaseAuthProvider - Abstract base class for authentication providers * * Provides common functionality for all auth providers including: * - Token extraction (header, cookie, query param, custom function) * - Session management (create, validate, refresh, revoke) * - RBAC authorization (roles, permissions, wildcards, hierarchy) * - Token validation utilities (JWT parsing, expiry checks) * - Event emission for auth lifecycle hooks * - Error handling via unified AuthError factory */ import { randomUUID } from "crypto"; import { EventEmitter } from "events"; import { logger } from "../../utils/logger.js"; import { AuthError } from "../errors.js"; // ============================================================================= // BACKWARD-COMPAT RE-EXPORTS // ============================================================================= /** * @deprecated Use `AuthError` from `../errors.js` instead. * Kept for backward compatibility with CognitoProvider / KeycloakProvider. */ export const AuthProviderError = AuthError; // ============================================================================= // IN-MEMORY SESSION STORAGE // ============================================================================= /** * Default in-memory session storage */ export class InMemorySessionStorage { sessions = new Map(); userSessions = new Map(); async get(sessionId) { return this.sessions.get(sessionId) ?? null; } async save(session) { this.sessions.set(session.id, session); // Track sessions by user const userSessionSet = this.userSessions.get(session.user.id) ?? new Set(); userSessionSet.add(session.id); this.userSessions.set(session.user.id, userSessionSet); } async delete(sessionId) { const session = this.sessions.get(sessionId); if (session) { this.sessions.delete(sessionId); // Remove from user tracking const userSessionSet = this.userSessions.get(session.user.id); if (userSessionSet) { userSessionSet.delete(sessionId); if (userSessionSet.size === 0) { this.userSessions.delete(session.user.id); } } } } async deleteAllForUser(userId) { const userSessionSet = this.userSessions.get(userId); if (userSessionSet) { for (const sessionId of userSessionSet) { this.sessions.delete(sessionId); } this.userSessions.delete(userId); } } async getForUser(userId) { const userSessionSet = this.userSessions.get(userId); if (!userSessionSet) { return []; } const now = Date.now(); const sessions = []; const expiredIds = []; for (const sessionId of userSessionSet) { const session = this.sessions.get(sessionId); if (!session) { continue; } // Filter out expired and revoked sessions so maxSessionsPerUser counts are accurate if (session.expiresAt && session.expiresAt.getTime() < now) { expiredIds.push(sessionId); continue; } if (!session.isValid) { expiredIds.push(sessionId); continue; } sessions.push(session); } // Clean up expired sessions lazily for (const id of expiredIds) { this.sessions.delete(id); userSessionSet.delete(id); } if (userSessionSet.size === 0) { this.userSessions.delete(userId); } return sessions; } async exists(sessionId) { return this.sessions.has(sessionId); } async touch(sessionId) { const session = this.sessions.get(sessionId); if (session) { session.lastActivityAt = new Date(); this.sessions.set(sessionId, session); } } async clear() { this.sessions.clear(); this.userSessions.clear(); } /** * Get session count (for testing/monitoring) */ get size() { return this.sessions.size; } } // ============================================================================= // BASE PROVIDER IMPLEMENTATION // ============================================================================= /** * BaseAuthProvider - Abstract base class for all auth providers * * Subclasses must implement: * - authenticateToken() - Validate and decode JWT/access tokens * * Optionally override: * - getUser() - Fetch user by ID from provider * - updateUserRoles() - Update user roles in provider * - updateUserPermissions() - Update user permissions in provider * - dispose() - Clean up resources */ export class BaseAuthProvider { config; sessionStorage; sessionConfig; rbacConfig; emitter = new EventEmitter(); constructor(config) { // Deep-merge tokenExtraction: preserve header defaults when partial config given const defaultTokenExtraction = { fromHeader: { name: "Authorization", scheme: "Bearer" }, }; this.config = { required: true, ...config, tokenExtraction: { ...defaultTokenExtraction, ...config.tokenExtraction, }, }; // Initialize session configuration this.sessionConfig = { storage: "memory", duration: 3600, // 1 hour default autoRefresh: true, refreshThreshold: 300, // 5 minutes allowMultipleSessions: true, maxSessionsPerUser: 10, prefix: "neurolink:session:", ...config.session, }; // Initialize RBAC configuration this.rbacConfig = { enabled: true, defaultRoles: [], roleHierarchy: {}, rolePermissions: {}, superAdminRoles: ["super_admin", "root"], ...config.rbac, }; // Initialize session storage this.sessionStorage = config.session?.customStorage ?? new InMemorySessionStorage(); logger.debug(`[BaseAuthProvider] Initialized`); } // =========================================================================== // TOKEN EXTRACTION // =========================================================================== /** * Extract token using configured strategy * * Attempts extraction in order: * 1. Header (Authorization: Bearer <token> by default) * 2. Cookie * 3. Query parameter * 4. Custom function * * @param context - Request context containing headers, cookies, etc. * @returns Extracted token or null if not found */ async extractToken(context) { const strategy = this.config.tokenExtraction; // Try header extraction (case-insensitive header lookup) if (strategy?.fromHeader) { const headerName = strategy.fromHeader.name.toLowerCase(); // Find header value with case-insensitive lookup let headerValue; for (const [key, value] of Object.entries(context.headers)) { if (key.toLowerCase() === headerName && typeof value === "string") { headerValue = value; break; } } if (typeof headerValue === "string") { if (strategy.fromHeader.scheme) { const prefix = `${strategy.fromHeader.scheme} `; if (headerValue.startsWith(prefix)) { return headerValue.slice(prefix.length); } } else { return headerValue; } } } // Try cookie extraction if (strategy?.fromCookie && context.cookies) { const cookieValue = context.cookies[strategy.fromCookie.name]; if (cookieValue) { return cookieValue; } } // Try query parameter extraction if (strategy?.fromQuery && context.path) { try { const url = new URL(context.path, "http://localhost"); const queryValue = url.searchParams.get(strategy.fromQuery.name); if (queryValue) { return queryValue; } } catch { // Invalid URL, skip query extraction } } // Try custom extraction (may be sync or async) if (strategy?.custom) { return await Promise.resolve(strategy.custom(context)); } return null; } // =========================================================================== // SESSION MANAGEMENT // =========================================================================== /** * Create a new session for an authenticated user * * Session duration and metadata are derived from `this.sessionConfig` and * the optional `context`. This matches the `AuthSessionManager` type * signature: `createSession(user, context?)`. */ async createSession(user, context) { const now = new Date(); const duration = this.sessionConfig.duration ?? 3600; // Check session limits if (!this.sessionConfig.allowMultipleSessions) { await this.revokeAllSessions(user.id); } else if (this.sessionConfig.maxSessionsPerUser) { const existingSessions = await this.sessionStorage.getForUser(user.id); if (existingSessions.length >= this.sessionConfig.maxSessionsPerUser) { // Remove oldest session const oldestSession = existingSessions.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())[0]; if (oldestSession) { await this.sessionStorage.delete(oldestSession.id); } } } const session = { id: randomUUID(), user, accessToken: randomUUID(), // Internal session token isValid: true, expiresAt: new Date(now.getTime() + duration * 1000), createdAt: now, lastActivityAt: now, ipAddress: context?.ip ?? context?.ipAddress, userAgent: context?.userAgent, }; await this.sessionStorage.save(session); logger.debug(`[BaseAuthProvider] Created session ${session.id} for user ${user.id}`); return session; } /** * Validate an existing session */ async validateSession(sessionId) { const session = await this.sessionStorage.get(sessionId); if (!session) { return { valid: false, error: "Session not found", errorCode: "AUTH-010", }; } // Check expiration if (session.expiresAt && session.expiresAt.getTime() < Date.now()) { await this.sessionStorage.delete(sessionId); return { valid: false, error: "Session expired", errorCode: "AUTH-011", }; } // Check if revoked if (!session.isValid) { return { valid: false, error: "Session revoked", errorCode: "AUTH-012", }; } // Auto-refresh if near expiration let refreshed = false; if (this.sessionConfig.autoRefresh && this.sessionConfig.refreshThreshold && session.expiresAt && session.expiresAt.getTime() - Date.now() < this.sessionConfig.refreshThreshold * 1000) { const refreshedSession = await this.refreshSession(sessionId); refreshed = true; return { valid: true, session: refreshedSession ?? undefined, refreshed, }; } // Update last activity await this.sessionStorage.touch(sessionId); return { valid: true, session, refreshed, }; } /** * Refresh a session (extend expiration) */ async refreshSession(sessionId) { const session = await this.sessionStorage.get(sessionId); if (!session) { throw AuthError.create("SESSION_NOT_FOUND", `Session not found: ${sessionId}`, { details: { sessionId } }); } // Don't refresh revoked sessions if (!session.isValid) { throw AuthError.create("SESSION_REVOKED", `Cannot refresh revoked session: ${sessionId}`, { details: { sessionId } }); } // Don't refresh expired sessions if (session.expiresAt && session.expiresAt.getTime() < Date.now()) { await this.sessionStorage.delete(sessionId); throw AuthError.create("SESSION_EXPIRED", `Cannot refresh expired session: ${sessionId}`, { details: { sessionId } }); } const duration = this.sessionConfig.duration ?? 3600; session.expiresAt = new Date(Date.now() + duration * 1000); session.lastActivityAt = new Date(); await this.sessionStorage.save(session); logger.debug(`[BaseAuthProvider] Refreshed session ${sessionId}`); return session; } /** * Revoke a session * * Marks the session as invalid rather than deleting it immediately. * This keeps a tombstone so that "revoked" is distinguishable from * "not found" during subsequent validation attempts. */ async revokeSession(sessionId) { const session = await this.sessionStorage.get(sessionId); if (session) { session.isValid = false; await this.sessionStorage.save(session); logger.debug(`[BaseAuthProvider] Revoked session ${sessionId}`); } } /** * Revoke all sessions for a user */ async revokeAllSessions(userId) { await this.sessionStorage.deleteAllForUser(userId); logger.debug(`[BaseAuthProvider] Revoked all sessions for user ${userId}`); } // =========================================================================== // AUTHORIZATION (RBAC) // =========================================================================== /** * Check if a user is authorized for specific roles/permissions */ async authorize(user, options) { // Check if RBAC is enabled if (!this.rbacConfig.enabled) { return { authorized: true, user }; } // Super admin bypass if (this.isSuperAdmin(user)) { return { authorized: true, user }; } const result = { authorized: true, user, requiredRoles: options.roles, requiredPermissions: options.permissions, missingRoles: [], missingPermissions: [], }; // Check roles if (options.roles && options.roles.length > 0) { const userRoles = this.getEffectiveRoles(user); const missingRoles = options.roles.filter((r) => !userRoles.has(r)); if (options.requireAllRoles) { // All roles required if (missingRoles.length > 0) { result.authorized = false; result.missingRoles = missingRoles; result.reason = `Missing required roles: ${missingRoles.join(", ")}`; } } else { // Any role is sufficient const hasAnyRole = options.roles.some((r) => userRoles.has(r)); if (!hasAnyRole) { result.authorized = false; result.missingRoles = options.roles; result.reason = `Missing any of required roles: ${options.roles.join(", ")}`; } } } // Check permissions (all required) if (options.permissions && options.permissions.length > 0) { const userPermissions = this.getEffectivePermissions(user); const missingPermissions = options.permissions.filter((p) => !this.hasPermission(userPermissions, p)); if (missingPermissions.length > 0) { result.authorized = false; result.missingPermissions = missingPermissions; result.reason = result.reason ? `${result.reason}; Missing permissions: ${missingPermissions.join(", ")}` : `Missing required permissions: ${missingPermissions.join(", ")}`; } } return result; } /** * Check if user is a super admin */ isSuperAdmin(user) { const superAdminRoles = this.rbacConfig.superAdminRoles ?? []; return user.roles.some((r) => superAdminRoles.includes(r)); } /** * Get effective roles including inherited roles from hierarchy (transitive) */ getEffectiveRoles(user) { const effectiveRoles = new Set(user.roles); // Transitive closure: keep expanding until no new roles are added const hierarchy = this.rbacConfig.roleHierarchy ?? {}; let added = true; while (added) { added = false; for (const role of effectiveRoles) { const inheritedRoles = hierarchy[role] ?? []; for (const inherited of inheritedRoles) { if (!effectiveRoles.has(inherited)) { effectiveRoles.add(inherited); added = true; } } } } return effectiveRoles; } /** * Get effective permissions including role-based permissions */ getEffectivePermissions(user) { const effectivePermissions = new Set(user.permissions); // Add permissions from roles const rolePermissions = this.rbacConfig.rolePermissions ?? {}; const effectiveRoles = this.getEffectiveRoles(user); for (const role of effectiveRoles) { const permissions = rolePermissions[role] ?? []; for (const permission of permissions) { effectivePermissions.add(permission); } } return effectivePermissions; } /** * Check if a permission set grants a given permission. * Supports exact match, global wildcard ("*"), and hierarchical wildcards * (e.g. "tools:*" grants "tools:execute"). */ hasPermission(permissions, required) { if (permissions.has(required)) { return true; } if (permissions.has("*")) { return true; } const parts = required.split(":"); for (let i = parts.length - 1; i > 0; i--) { const wildcard = [...parts.slice(0, i), "*"].join(":"); if (permissions.has(wildcard)) { return true; } } return false; } // =========================================================================== // UTILITY METHODS // =========================================================================== /** * Parse JWT token (without validation) */ parseJWT(token) { try { const parts = token.split("."); if (parts.length !== 3) { return null; } const payload = parts[1]; const decoded = Buffer.from(payload, "base64url").toString("utf-8"); return JSON.parse(decoded); } catch { return null; } } /** * Check if token is expired */ isTokenExpired(claims, clockTolerance = 0) { if (!claims.exp) { return false; // No expiration claim } const now = Math.floor(Date.now() / 1000); return claims.exp + clockTolerance < now; } /** * Check if token is not yet valid */ isTokenNotYetValid(claims, clockTolerance = 0) { if (!claims.nbf) { return false; // No nbf claim } const now = Math.floor(Date.now() / 1000); return claims.nbf - clockTolerance > now; } /** * Extract user from token claims */ extractUserFromClaims(claims, options) { const rolesKey = options?.rolesClaimKey ?? "roles"; const permissionsKey = options?.permissionsClaimKey ?? "permissions"; const idKey = options?.idClaimKey ?? "sub"; const roles = Array.isArray(claims[rolesKey]) ? claims[rolesKey] : (this.rbacConfig.defaultRoles ?? []); const permissions = Array.isArray(claims[permissionsKey]) ? claims[permissionsKey] : []; return { id: claims[idKey] ?? "", email: claims.email, name: claims.name, picture: claims.picture, roles, permissions, emailVerified: claims.email_verified, providerData: claims, }; } // =========================================================================== // OPTIONAL METHODS (can be overridden by subclasses) // =========================================================================== /** * Get user by ID * Override in subclass if provider supports user lookup */ async getUser(_userId) { logger.debug(`[BaseAuthProvider] getUser not implemented for ${this.type}`); return null; } /** * Update user roles * Override in subclass if provider supports role updates. * Returns the user with updated roles. */ async updateUserRoles(_userId, _roles) { throw AuthError.create("PROVIDER_ERROR", `updateUserRoles not supported by ${this.type} provider`); } /** * Update user permissions * Override in subclass if provider supports permission updates. * Returns the user with updated permissions. */ async updateUserPermissions(_userId, _permissions) { throw AuthError.create("PROVIDER_ERROR", `updateUserPermissions not supported by ${this.type} provider`); } /** * Clean up resources */ async dispose() { await this.sessionStorage.clear(); logger.debug(`[BaseAuthProvider] Disposed ${this.type} provider`); } // =========================================================================== // METHODS FROM AuthProvider INTERFACE // =========================================================================== /** * Check if a user is authorized to perform an action */ async authorizeUser(user, permission) { return this.authorize(user, { permissions: [permission] }); } /** * Check if user has specific roles */ async authorizeRoles(user, roles) { return this.authorize(user, { roles }); } /** * Check if user has all specified permissions */ async authorizePermissions(user, permissions) { return this.authorize(user, { permissions }); } /** * Get an existing session by ID */ async getSession(sessionId) { return this.sessionStorage.get(sessionId); } /** * Invalidate/destroy a session */ async destroySession(sessionId) { await this.revokeSession(sessionId); } /** * Get all active sessions for a user */ async getUserSessions(userId) { return this.sessionStorage.getForUser(userId); } /** * Invalidate all sessions for a user (global logout) */ async destroyAllUserSessions(userId) { await this.revokeAllSessions(userId); } /** * Full request authentication flow * * Combines token extraction (with full strategy support), validation, * and session creation/reuse. * * @param context - Request context * @returns Authenticated context with user and session, or null */ async authenticateRequest(context) { // Extract token (async to support custom extractors) const token = await this.extractToken(context); if (!token) { if (!this.config.required) { return null; } this.emitter.emit("auth:unauthorized", context, "No token provided"); return null; } // Validate token const validation = await this.authenticateToken(token, context); if (!validation.valid || !validation.user) { this.emitter.emit("auth:unauthorized", context, validation.error ?? "Invalid token"); return null; } // Reuse existing session if one exists for this user const existingSessions = await this.getUserSessions(validation.user.id); const validSession = existingSessions.find((s) => s.isValid && (!s.expiresAt || s.expiresAt.getTime() > Date.now())); const session = validSession ?? (await this.createSession(validation.user, context)); return { ...context, user: validation.user, session, request: context, authenticatedAt: new Date(), provider: this.type, }; } /** * Check provider health */ async healthCheck() { return { healthy: true, providerConnected: true, sessionStorageHealthy: true, }; } // =========================================================================== // EVENT HELPERS // =========================================================================== /** * Subscribe to auth events */ on(event, listener) { this.emitter.on(event, listener); } /** * Unsubscribe from auth events */ off(event, listener) { this.emitter.off(event, listener); } /** * Emit an auth event */ emit(event, ...args) { this.emitter.emit(event, ...args); } } //# sourceMappingURL=BaseAuthProvider.js.map