UNPKG

@nekzus/tokenly

Version:

Secure JWT token management with advanced device fingerprinting

3 lines (2 loc) 9.94 kB
"use strict";var e,t=require("crypto"),n=require("jsonwebtoken");!function(e){e.INVALID_TOKEN="INVALID_TOKEN",e.TOKEN_EXPIRED="TOKEN_EXPIRED",e.TOKEN_REVOKED="TOKEN_REVOKED",e.INVALID_FINGERPRINT="INVALID_FINGERPRINT",e.MAX_DEVICES_REACHED="MAX_DEVICES_REACHED",e.MAX_ROTATION_EXCEEDED="MAX_ROTATION_EXCEEDED",e.INVALID_PAYLOAD="INVALID_PAYLOAD",e.EMPTY_PAYLOAD="EMPTY_PAYLOAD",e.MISSING_USER_ID="MISSING_USER_ID",e.INVALID_USER_ID="INVALID_USER_ID",e.INVALID_CONTEXT="INVALID_CONTEXT",e.MISSING_ENV_VAR="MISSING_ENV_VAR"}(e||(e={}));class r extends Error{constructor(e,t,n){super(t),this.code=e,this.details=n,this.name="TokenlyError"}}const i={[e.INVALID_TOKEN]:"Invalid token format or signature",[e.TOKEN_EXPIRED]:"Token has expired",[e.TOKEN_REVOKED]:"Token has been revoked",[e.INVALID_FINGERPRINT]:"Invalid token fingerprint",[e.MAX_DEVICES_REACHED]:"Maximum number of devices reached",[e.MAX_ROTATION_EXCEEDED]:"Maximum rotation count exceeded",[e.INVALID_PAYLOAD]:"Invalid payload format",[e.EMPTY_PAYLOAD]:"Payload cannot be empty",[e.MISSING_USER_ID]:"Payload must contain a userId",[e.INVALID_USER_ID]:"Invalid userId format or value",[e.INVALID_CONTEXT]:"Invalid or empty context values",[e.MISSING_ENV_VAR]:"Missing required environment variable"};function s(e,t){throw new r(e,i[e],t)}exports.Tokenly=class{constructor(e){this.currentToken=null,this.blacklistedTokens=new Set,this.deviceTokens=new Map,this.rotationCounts=new Map,this.revokedTokens=new Set,this.autoRotationInterval=null,this.fingerprintCache=new Map,this.instanceId=t.randomBytes(16).toString("hex"),process.env.JWT_SECRET_ACCESS&&process.env.JWT_SECRET_REFRESH||console.warn("%s%s",`WARNING: Using auto-generated secrets. This is secure but tokens will be invalidated on server restart. \n For production, please set JWT_SECRET_ACCESS and JWT_SECRET_REFRESH environment variables.\n Instance ID: ${this.instanceId}\n Documentation: `,"https://nekzus.github.io/tokenly/guide/security.html#environment-variables"),this.secretAccess=process.env.JWT_SECRET_ACCESS||this.generateSecret("access"),this.secretRefresh=process.env.JWT_SECRET_REFRESH||this.generateSecret("refresh"),this.accessTokenExpiry=e?.accessTokenExpiry||process.env.ACCESS_TOKEN_EXPIRY||"15m",this.refreshTokenExpiry=e?.refreshTokenExpiry||process.env.REFRESH_TOKEN_EXPIRY||"7d",this.cookieOptions={httpOnly:!0,secure:"production"===process.env.NODE_ENV,sameSite:"strict",path:"/",maxAge:6048e5,...e?.cookieOptions},this.jwtOptions={algorithm:"HS512",issuer:"tokenly-auth",audience:"tokenly-client",...e?.jwtOptions},this.verifyOptions={algorithms:[this.jwtOptions.algorithm],issuer:this.jwtOptions.issuer,audience:this.jwtOptions.audience,clockTolerance:30},this.rotationConfig={enableAutoRotation:!0,rotationInterval:60,maxRotationCount:100,...e?.rotationConfig},this.securityConfig={enableFingerprint:!0,enableBlacklist:!0,maxDevices:5,revokeOnSecurityBreach:!0,...e?.securityConfig},this.eventListeners=new Map,this.tokenCache=new Map}generateSecret(e){return t.createHash("sha256").update(`${this.instanceId}-${e}-${Date.now()}`).digest("hex")}formatDate(e){return new Date(1e3*e).toISOString()}decodeWithReadableDates(e,t){t||(t=n.decode(e));const{iat:r,exp:i,...s}=t;return{raw:e,payload:{...s,iat:r?this.formatDate(r):void 0,exp:i?this.formatDate(i):void 0}}}generateFingerprint(n){n?.userAgent?.trim()&&n?.ip?.trim()||s(e.INVALID_CONTEXT,"Invalid or empty context values");const r=n.userAgent.trim().toLowerCase().replace(/\s+/g," "),i=n.ip.trim().toLowerCase().replace(/[^0-9.]/g,""),o=`ua=${t.createHash("sha256").update(`ua:${this.instanceId}:${r}`).digest("hex")}|ip=${t.createHash("sha256").update(`ip:${this.instanceId}:${i}`).digest("hex")}`;return t.createHash("sha256").update(o).digest("hex")}revokeToken(e){if(e)try{const t=n.decode(e);this.revokedTokens.add(e),this.emit("tokenRevoked",{token:e,userId:t?.userId,timestamp:Date.now()})}catch(e){console.error("Error al revocar token:",e)}}isTokenBlacklisted(e){return this.securityConfig.enableBlacklist&&this.blacklistedTokens.has(e)}validatePayload(t){null!==t&&"object"==typeof t||s(e.INVALID_PAYLOAD),0===Object.keys(t).length&&s(e.EMPTY_PAYLOAD),Object.prototype.hasOwnProperty.call(t,"userId")||s(e.MISSING_USER_ID),null!==t.userId&&void 0!==t.userId||s(e.INVALID_USER_ID),"string"==typeof t.userId&&t.userId.trim()||s(e.INVALID_USER_ID),Object.entries(t).forEach((([t,n])=>{null==n&&s(e.INVALID_PAYLOAD,`Payload property '${t}' cannot be null or undefined`)}));JSON.stringify(t).length>8192&&s(e.INVALID_PAYLOAD,"Payload size exceeds maximum allowed size")}generateAccessToken(e,t,r){this.validatePayload(e);const i={...e};if(this.securityConfig.enableFingerprint&&r){const t=this.generateFingerprint(r),n=e.userId;this.handleDeviceStorage(n,t),i.fingerprint=t}const s=n.sign(i,this.secretAccess,{...this.jwtOptions,...t,expiresIn:this.accessTokenExpiry}),o=this.decodeWithReadableDates(s);return this.cacheToken(s,o),o}verifyAccessToken(t,i){this.revokedTokens.has(t)&&s(e.TOKEN_REVOKED),this.isTokenBlacklisted(t)&&s(e.TOKEN_REVOKED,"Token is blacklisted");try{const r=n.verify(t,this.secretAccess,{...this.verifyOptions,ignoreExpiration:!1,clockTolerance:0});if(this.securityConfig.enableFingerprint&&i){const t=this.generateFingerprint(i);r.fingerprint&&r.fingerprint!==t&&s(e.INVALID_FINGERPRINT)}const o=this.decodeWithReadableDates(t,r);return this.cacheToken(t,o),o}catch(t){if(t instanceof r)throw t;throw"TokenExpiredError"===t.name&&s(e.TOKEN_EXPIRED),"JsonWebTokenError"===t.name&&s(e.INVALID_TOKEN),t}}generateRefreshToken(e,t){this.validatePayload(e);const r={...e};delete r.aud,delete r.iss,delete r.exp,delete r.iat;const i=n.sign(r,this.secretRefresh,{...this.jwtOptions,expiresIn:this.refreshTokenExpiry}),s=this.decodeWithReadableDates(i);return s.cookieConfig={name:"refresh_token",value:i,options:{...this.cookieOptions,...t}},s}verifyRefreshToken(e){const t=n.verify(e,this.secretRefresh,this.verifyOptions);return this.decodeWithReadableDates(e,t)}rotateTokens(t){t&&"string"==typeof t||s(e.INVALID_TOKEN,"Invalid refresh token format");const n=this.verifyRefreshToken(t),{iat:r,exp:i,aud:o,iss:a,...c}=n.payload,h=t,l=this.rotationCounts.get(h)||0;l>=(this.rotationConfig.maxRotationCount||2)&&s(e.MAX_ROTATION_EXCEEDED),this.rotationCounts.set(h,l+1);const d={...c,iat:Math.floor(Date.now()/1e3)};return{accessToken:this.generateAccessToken(d),refreshToken:this.generateRefreshToken(d)}}setToken(e){this.currentToken=e}getToken(){return this.currentToken}clearToken(){this.currentToken=null}isTokenExpiringSoon(e,t=5){try{const r=n.decode(e);if(!r||!r.exp)return!1;const i=1e3*r.exp,s=Date.now();return i-s<60*t*1e3}catch{return!1}}getTokenInfo(e){try{const t=n.decode(e);return t?{userId:t.userId,expiresAt:new Date(1e3*t.exp),issuedAt:new Date(1e3*t.iat),fingerprint:t.fingerprint}:null}catch{return null}}validateTokenFormat(e){try{const t=e.split(".");return 3===t.length&&t.every((e=>{try{return Buffer.from(e,"base64").toString(),!0}catch{return!1}}))}catch{return!1}}generateOneTimeToken(e,r="5m"){const i={purpose:e,nonce:t.randomBytes(16).toString("hex"),iat:Math.floor(Date.now()/1e3)};return n.sign(i,this.secretAccess,{expiresIn:r})}verifyRefreshTokenEnhanced(t){this.validateTokenFormat(t)||s(e.INVALID_TOKEN,"Invalid token format");const n=this.verifyRefreshToken(t);return this.isTokenExpiringSoon(t,60)&&s(e.TOKEN_EXPIRED,"Refresh token is about to expire"),n}on(e,t){this.eventListeners.has(e)||this.eventListeners.set(e,[]),this.eventListeners.get(e)?.push(t)}emit(e,t){const n=this.eventListeners.get(e);n?.length&&n.forEach((e=>{try{e(t)}catch(e){console.error("Error executing event listener:",e)}}))}cacheToken(e,t){this.tokenCache.set(e,t),setTimeout((()=>{this.tokenCache.delete(e)}),3e5)}analyzeTokenSecurity(e){const t=n.decode(e,{complete:!0});if(!t)throw new Error("Invalid token");return{algorithm:t.header.alg,hasFingerprint:!!t.payload.fingerprint,expirationTime:new Date(1e3*t.payload.exp),issuedAt:new Date(1e3*t.payload.iat),timeUntilExpiry:1e3*t.payload.exp-Date.now(),strength:this.calculateTokenStrength(t)}}calculateTokenStrength(e){let t=0;"HS512"===e.header.alg?t+=2:"HS256"===e.header.alg&&(t+=1),e.payload.fingerprint&&(t+=2);const n=1e3*e.payload.exp-Date.now();return n<9e5?t+=1:n<36e5&&(t+=2),t<=2?"weak":t<=4?"medium":"strong"}enableAutoRotation(e={}){console.log("Enabling auto rotation...");const{checkInterval:t=50,rotateBeforeExpiry:n=1e3}=e;return this.autoRotationInterval&&clearInterval(this.autoRotationInterval),this.checkTokensExpiration(n),this.autoRotationInterval=setInterval((()=>{this.checkTokensExpiration(n)}),t),this.autoRotationInterval}disableAutoRotation(){this.autoRotationInterval&&(clearInterval(this.autoRotationInterval),this.autoRotationInterval=null)}checkTokensExpiration(e){Array.from(this.tokenCache.entries()).forEach((([t,r])=>{try{const r=n.decode(t);if(r?.exp){const n=1e3*r.exp-Date.now();n<e&&this.emit("tokenExpiring",{token:t,userId:r.userId,expiresIn:n})}}catch(e){console.error("Error checking token expiration:",e)}}))}enableAutoCleanup(e=36e5){setInterval((()=>{const e=Date.now();this.revokedTokens.forEach((t=>{try{const r=n.decode(t);r&&r.exp&&1e3*r.exp<e&&this.revokedTokens.delete(t)}catch{this.revokedTokens.delete(t)}}))}),e)}handleDeviceStorage(t,n){this.deviceTokens.has(t)||this.deviceTokens.set(t,new Set);const r=this.deviceTokens.get(t),i=`${t}:${n}`;this.fingerprintCache.has(i)||(r.size>=this.securityConfig.maxDevices&&s(e.MAX_DEVICES_REACHED,{userId:t,currentDevices:r.size,maxDevices:this.securityConfig.maxDevices}),this.fingerprintCache.set(i,n)),r.add(n)}},exports.getClientIP=function(e,t="0.0.0.0"){if(!e||"object"!=typeof e)return t;const n=e["x-real-ip"];if("string"==typeof n&&n.trim())return n.trim();const r=e["x-forwarded-for"];if("string"==typeof r&&r.trim()){return r.split(",")[0].trim()||t}return t}; //# sourceMappingURL=index.cjs.map