UNPKG

@nibssplc/cams-sdk

Version:

Central Authentication Management Service (CAMS) SDK for popup-based authentication with Azure AD + custom 2FA

393 lines (384 loc) 17.5 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('zod')) : typeof define === 'function' && define.amd ? define(['exports', 'zod'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.CAMS = {}, global.zod)); })(this, (function (exports, zod) { 'use strict'; exports.CAMSErrorType = void 0; (function (CAMSErrorType) { CAMSErrorType["POPUP_BLOCKED"] = "POPUP_BLOCKED"; CAMSErrorType["TIMEOUT"] = "TIMEOUT"; CAMSErrorType["INVALID_ORIGIN"] = "INVALID_ORIGIN"; CAMSErrorType["USER_CANCELLED"] = "USER_CANCELLED"; CAMSErrorType["INVALID_URL"] = "INVALID_URL"; CAMSErrorType["MAX_RETRIES_EXCEEDED"] = "MAX_RETRIES_EXCEEDED"; })(exports.CAMSErrorType || (exports.CAMSErrorType = {})); class CAMSError extends Error { constructor(type, message) { super(message); this.type = type; this.name = 'CAMSError'; } } // Validate the returned token - Base64 format with minimum 20 chars const TokenMessageSchema = zod.z.object({ type: zod.z.literal("AUTH_SUCCESS"), accessToken: zod.z.string().min(1, "Access token cannot be empty"), user: zod.z.object({ name: zod.z.string(), image: zod.z.string().nullable(), }).optional(), }); const ErrorMessageSchema = zod.z.object({ error: zod.z.string().min(1, "Error message cannot be empty"), }); const TokenSchema = zod.z.object({ access_token: zod.z.string(), refresh_token: zod.z.string().optional(), expires_in: zod.z.number(), // in seconds token_type: zod.z.string().default("Bearer"), id_token: zod.z.string().optional(), }); const ProfileSchema = zod.z.object({ sub: zod.z.string(), // user ID name: zod.z.string().optional(), email: zod.z.email({ message: "Invalid email address" }).optional(), given_name: zod.z.string().optional(), family_name: zod.z.string().optional(), roles: zod.z.array(zod.z.string()).optional(), }); // Matches IPv4 addresses like 192.168.x.x, 10.x.x.x, 172.16–31.x.x const internalIpRegex = /^(http:\/\/|https:\/\/)((10\.\d{1,3}\.\d{1,3}\.\d{1,3})|(192\.168\.\d{1,3}\.\d{1,3})|(172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}))(:\d+)?$/; const URLSchema = zod.z.url().refine((url) => url.startsWith("https://") || // normal HTTPS /^http:\/\/localhost(:\d+)?$/i.test(url) || // localhost internalIpRegex.test(url), // internal LAN IPs { message: "Only HTTPS, http://localhost, or internal IPs (192.168.x.x, 10.x.x.x, 172.16–31.x.x) are allowed", }); exports.LogLevel = void 0; (function (LogLevel) { LogLevel[LogLevel["ERROR"] = 0] = "ERROR"; LogLevel[LogLevel["WARN"] = 1] = "WARN"; LogLevel[LogLevel["INFO"] = 2] = "INFO"; LogLevel[LogLevel["DEBUG"] = 3] = "DEBUG"; })(exports.LogLevel || (exports.LogLevel = {})); class Logger { static setLevel(level) { Logger.level = level; } static error(message, context) { if (!Logger.isTestEnv && Logger.level >= exports.LogLevel.ERROR) { console.error(`${Logger.prefix} ERROR:`, message, context || ''); } } static warn(message, context) { if (!Logger.isTestEnv && Logger.level >= exports.LogLevel.WARN) { console.warn(`${Logger.prefix} WARN:`, message, context || ''); } } static info(message, context) { if (!Logger.isTestEnv && Logger.level >= exports.LogLevel.INFO) { console.info(`${Logger.prefix} INFO:`, message, context || ''); } } static debug(message, context) { if (!Logger.isTestEnv && Logger.level >= exports.LogLevel.DEBUG) { console.debug(`${Logger.prefix} DEBUG:`, message, context || ''); } } } Logger.level = exports.LogLevel.WARN; Logger.prefix = '[CAMS-SDK]'; Logger.isTestEnv = typeof process !== 'undefined' && process.env?.NODE_ENV === 'test'; function validateConfig(config) { // Validate URL format const urlValidation = URLSchema.safeParse(config.camsUrl); if (!urlValidation.success) { throw new CAMSError(exports.CAMSErrorType.INVALID_URL, 'Invalid CAMS URL format'); } // Domain validation is mandatory for security if (!config.allowedDomains?.length) { throw new CAMSError(exports.CAMSErrorType.INVALID_URL, 'allowedDomains must be specified for security'); } const url = new URL(config.camsUrl); // URLSchema already validated this const isAllowed = config.allowedDomains.some(domain => url.hostname === domain || url.hostname.endsWith('.' + domain)); if (!isAllowed) { throw new CAMSError(exports.CAMSErrorType.INVALID_URL, 'URL not in allowed domains list'); } } function openCAMSPopUpLogin(config) { return new Promise((resolve, reject) => { if (config.debug) Logger.setLevel(exports.LogLevel.DEBUG); Logger.info('Starting CAMS authentication', { url: config.camsUrl }); try { validateConfig(config); Logger.debug('Config validation passed'); } catch (error) { Logger.error('Config validation failed', { error: error instanceof Error ? error.message : 'Unknown error' }); reject(error); return; } const { camsUrl, allowedOrigin, windowHeight, windowWidth, timeout = 300000 } = config; let authWindow; let timeoutId = null; let checkClosedInterval = null; try { Logger.debug('Opening popup window', { width: windowWidth, height: windowHeight }); authWindow = window.open(camsUrl, "NIBSS CAMS Login", `width=${windowWidth},height=${windowHeight}`); } catch (error) { Logger.error('Failed to open popup window', { error: error instanceof Error ? error.message : 'Unknown error' }); reject(new CAMSError(exports.CAMSErrorType.POPUP_BLOCKED, "Failed to open authentication window: " + (error instanceof Error ? error.message : "Unknown error"))); return; } if (!authWindow) { Logger.error('Popup window blocked'); reject(new CAMSError(exports.CAMSErrorType.POPUP_BLOCKED, "Failed to open authentication window. Please allow popups.")); return; } Logger.debug('Popup window opened successfully'); const cleanup = () => { window.removeEventListener("message", listener); if (timeoutId) clearTimeout(timeoutId); if (checkClosedInterval) clearInterval(checkClosedInterval); }; const cleanupAndClose = (error) => { cleanup(); try { if (authWindow && !authWindow.closed) { authWindow.close(); } } catch { // Ignore close errors } if (error) reject(error); }; const listener = (event) => { Logger.debug('Received message', { origin: event.origin, expectedOrigin: allowedOrigin, data: event.data }); if (event.origin !== allowedOrigin) { Logger.warn('Blocked message from unauthorized origin', { origin: event.origin, expected: allowedOrigin }); return; } // ✅ Validate payload const tokenMsg = TokenMessageSchema.safeParse(event.data); if (tokenMsg.success) { Logger.info('Authentication successful'); cleanupAndClose(); resolve({ token: tokenMsg.data.accessToken }); return; } const errorMsg = ErrorMessageSchema.safeParse(event.data); if (errorMsg.success) { Logger.warn('Authentication error received', { error: errorMsg.data.error }); cleanupAndClose(new CAMSError(exports.CAMSErrorType.USER_CANCELLED, "Authentication failed: " + errorMsg.data.error)); return; } Logger.error('Invalid message format received', { messageData: event.data, dataType: typeof event.data, tokenValidationError: tokenMsg.error?.issues, errorValidationError: errorMsg.error?.issues }); cleanupAndClose(new CAMSError(exports.CAMSErrorType.INVALID_ORIGIN, "Invalid message format")); }; // Timeout handler Logger.debug('Setting timeout', { timeout }); timeoutId = setTimeout(() => { Logger.warn('Authentication timeout', { timeout }); cleanupAndClose(new CAMSError(exports.CAMSErrorType.TIMEOUT, `Authentication timeout after ${timeout}ms`)); }, timeout); checkClosedInterval = setInterval(() => { if (authWindow?.closed) { Logger.info('Authentication window closed by user'); cleanupAndClose(new CAMSError(exports.CAMSErrorType.USER_CANCELLED, "Authentication window was closed")); } }, 2000); // Reduced polling frequency try { window.addEventListener("message", listener); } catch (error) { cleanup(); reject(new CAMSError(exports.CAMSErrorType.POPUP_BLOCKED, "Failed to set up message listener: " + (error instanceof Error ? error.message : "Unknown error"))); return; } }); } class TokenManager { constructor(storage = sessionStorage, storageKey) { this.storage = storage; this.token = null; this.storageKey = storageKey || "cams_token"; } setToken(token) { this.token = token; this.storage.setItem(this.storageKey, token); Logger.debug("Token stored", { storageKey: this.storageKey }); } loadToken() { if (this.token) return this.token; const raw = this.storage.getItem(this.storageKey); if (!raw) return null; this.token = raw; Logger.debug("Token loaded from storage"); return this.token; } clearToken() { this.token = null; this.storage.removeItem(this.storageKey); Logger.debug("Token cleared"); } getAccessToken() { const token = this.loadToken(); return token; } isExpired() { const token = this.loadToken(); if (!token) return true; // JWT expiration check if available if (token) { try { const parts = token.split("."); if (parts.length < 3) return true; // Invalid JWT format const payload = JSON.parse(atob(parts[1])); if (typeof payload.exp !== "number") return true; const exp = payload.exp * 1000; return Date.now() > exp; } catch (error) { Logger.warn("Failed to parse JWT token", { error: error instanceof Error ? error.message : "Unknown error", }); return true; } } // Fallback: return true for safety when expiration cannot be determined return true; } } class CAMSSessionManager { constructor(storage = sessionStorage, storageKey, events = {}) { this.tokenManager = new TokenManager(storage, storageKey); this.events = events; } async login(config) { try { Logger.info('Session login started'); this.events.onAuthStart && this.events.onAuthStart(); let attempts = 0; const maxAttempts = (config.retryAttempts ?? 0) + 1; Logger.debug('Login retry configuration', { maxAttempts }); while (attempts < maxAttempts) { try { attempts++; Logger.debug('Login attempt', { attempt: attempts, maxAttempts }); const response = await openCAMSPopUpLogin(config); this.tokenManager.setToken(response.token); Logger.info('Session login successful'); this.events.onAuthSuccess && this.events.onAuthSuccess(response); return response; } catch (error) { Logger.warn('Login attempt failed', { attempt: attempts, error: error instanceof Error ? error.message : 'Unknown error' }); if (attempts >= maxAttempts || !(error instanceof CAMSError)) { throw error; } // Only retry on specific error types if (![exports.CAMSErrorType.TIMEOUT, exports.CAMSErrorType.POPUP_BLOCKED].includes(error.type)) { throw error; } Logger.info('Retrying login', { nextAttempt: attempts + 1 }); } } throw new CAMSError(exports.CAMSErrorType.MAX_RETRIES_EXCEEDED, 'Max retry attempts exceeded'); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const camsError = error instanceof CAMSError ? error : new CAMSError(exports.CAMSErrorType.USER_CANCELLED, errorMessage); Logger.error('Session login failed', { error: camsError.message, type: camsError.type }); this.events.onAuthError && this.events.onAuthError(camsError); throw camsError; } } async logout() { Logger.info('Session logout'); this.tokenManager.clearToken(); } isAuthenticated() { const token = this.tokenManager.loadToken(); if (!token) { Logger.debug('No token found'); return false; } if (this.tokenManager.isExpired()) { Logger.info('Token expired'); this.events.onTokenExpired?.(); return false; } Logger.debug('Token is valid'); return true; } getAccessToken() { const token = this.tokenManager.loadToken(); if (!token) return null; if (this.tokenManager.isExpired()) { this.events.onTokenExpired?.(); return null; } return token; } decodeBase64Url(str) { // Convert base64url to base64 str = str.replace(/-/g, '+').replace(/_/g, '/'); // Add padding if needed while (str.length % 4) { str += '='; } return atob(str); } async getProfile() { const storedToken = this.tokenManager.loadToken(); if (!storedToken) return null; try { const parts = storedToken.split('.'); if (parts.length >= 3) { // JWT has header.payload.signature format try { const payload = JSON.parse(this.decodeBase64Url(parts[1])); return ProfileSchema.parse(payload); } catch (decodeError) { Logger.warn('Failed to decode JWT payload', { error: decodeError instanceof Error ? decodeError.message : 'Unknown error' }); } } } catch (error) { Logger.warn('Failed to extract profile from token', { error: error instanceof Error ? error.message : 'Unknown error' }); } return null; } } exports.CAMSError = CAMSError; exports.CAMSSessionManager = CAMSSessionManager; exports.ErrorMessageSchema = ErrorMessageSchema; exports.Logger = Logger; exports.ProfileSchema = ProfileSchema; exports.TokenManager = TokenManager; exports.TokenMessageSchema = TokenMessageSchema; exports.TokenSchema = TokenSchema; exports.URLSchema = URLSchema; exports.openCAMSPopUpLogin = openCAMSPopUpLogin; exports.validateConfig = validateConfig; })); //# sourceMappingURL=index.umd.js.map