@nibssplc/cams-sdk
Version:
Central Authentication Management Service (CAMS) SDK for popup-based authentication with Azure AD + custom 2FA
377 lines (369 loc) • 15.1 kB
JavaScript
import { z } from 'zod';
var CAMSErrorType;
(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";
})(CAMSErrorType || (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 = z.object({
type: z.literal("AUTH_SUCCESS"),
accessToken: z.string().min(1, "Access token cannot be empty"),
user: z.object({
name: z.string(),
image: z.string().nullable(),
}).optional(),
});
const ErrorMessageSchema = z.object({
error: z.string().min(1, "Error message cannot be empty"),
});
const TokenSchema = z.object({
access_token: z.string(),
refresh_token: z.string().optional(),
expires_in: z.number(), // in seconds
token_type: z.string().default("Bearer"),
id_token: z.string().optional(),
});
const ProfileSchema = z.object({
sub: z.string(), // user ID
name: z.string().optional(),
email: z.email({ message: "Invalid email address" }).optional(),
given_name: z.string().optional(),
family_name: z.string().optional(),
roles: z.array(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 = 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",
});
var LogLevel;
(function (LogLevel) {
LogLevel[LogLevel["ERROR"] = 0] = "ERROR";
LogLevel[LogLevel["WARN"] = 1] = "WARN";
LogLevel[LogLevel["INFO"] = 2] = "INFO";
LogLevel[LogLevel["DEBUG"] = 3] = "DEBUG";
})(LogLevel || (LogLevel = {}));
class Logger {
static setLevel(level) {
Logger.level = level;
}
static error(message, context) {
if (!Logger.isTestEnv && Logger.level >= LogLevel.ERROR) {
console.error(`${Logger.prefix} ERROR:`, message, context || '');
}
}
static warn(message, context) {
if (!Logger.isTestEnv && Logger.level >= LogLevel.WARN) {
console.warn(`${Logger.prefix} WARN:`, message, context || '');
}
}
static info(message, context) {
if (!Logger.isTestEnv && Logger.level >= LogLevel.INFO) {
console.info(`${Logger.prefix} INFO:`, message, context || '');
}
}
static debug(message, context) {
if (!Logger.isTestEnv && Logger.level >= LogLevel.DEBUG) {
console.debug(`${Logger.prefix} DEBUG:`, message, context || '');
}
}
}
Logger.level = 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(CAMSErrorType.INVALID_URL, 'Invalid CAMS URL format');
}
// Domain validation is mandatory for security
if (!config.allowedDomains?.length) {
throw new CAMSError(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(CAMSErrorType.INVALID_URL, 'URL not in allowed domains list');
}
}
function openCAMSPopUpLogin(config) {
return new Promise((resolve, reject) => {
if (config.debug)
Logger.setLevel(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(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(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(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(CAMSErrorType.INVALID_ORIGIN, "Invalid message format"));
};
// Timeout handler
Logger.debug('Setting timeout', { timeout });
timeoutId = setTimeout(() => {
Logger.warn('Authentication timeout', { timeout });
cleanupAndClose(new CAMSError(CAMSErrorType.TIMEOUT, `Authentication timeout after ${timeout}ms`));
}, timeout);
checkClosedInterval = setInterval(() => {
if (authWindow?.closed) {
Logger.info('Authentication window closed by user');
cleanupAndClose(new CAMSError(CAMSErrorType.USER_CANCELLED, "Authentication window was closed"));
}
}, 2000); // Reduced polling frequency
try {
window.addEventListener("message", listener);
}
catch (error) {
cleanup();
reject(new CAMSError(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 (![CAMSErrorType.TIMEOUT, CAMSErrorType.POPUP_BLOCKED].includes(error.type)) {
throw error;
}
Logger.info('Retrying login', { nextAttempt: attempts + 1 });
}
}
throw new CAMSError(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(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;
}
}
export { CAMSError, CAMSErrorType, CAMSSessionManager, ErrorMessageSchema, LogLevel, Logger, ProfileSchema, TokenManager, TokenMessageSchema, TokenSchema, URLSchema, openCAMSPopUpLogin, validateConfig };
//# sourceMappingURL=index.esm.js.map