UNPKG

@nekzus/tokenly

Version:

Secure JWT token management with advanced device fingerprinting

3 lines (2 loc) 9.94 kB
import e from"crypto";import t from"jsonwebtoken";var n;!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"}(n||(n={}));class r extends Error{constructor(e,t,n){super(t),this.code=e,this.details=n,this.name="TokenlyError"}}const i={[n.INVALID_TOKEN]:"Invalid token format or signature",[n.TOKEN_EXPIRED]:"Token has expired",[n.TOKEN_REVOKED]:"Token has been revoked",[n.INVALID_FINGERPRINT]:"Invalid token fingerprint",[n.MAX_DEVICES_REACHED]:"Maximum number of devices reached",[n.MAX_ROTATION_EXCEEDED]:"Maximum rotation count exceeded",[n.INVALID_PAYLOAD]:"Invalid payload format",[n.EMPTY_PAYLOAD]:"Payload cannot be empty",[n.MISSING_USER_ID]:"Payload must contain a userId",[n.INVALID_USER_ID]:"Invalid userId format or value",[n.INVALID_CONTEXT]:"Invalid or empty context values",[n.MISSING_ENV_VAR]:"Missing required environment variable"};function s(e,t){throw new r(e,i[e],t)}class o{constructor(t){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=e.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=t?.accessTokenExpiry||process.env.ACCESS_TOKEN_EXPIRY||"15m",this.refreshTokenExpiry=t?.refreshTokenExpiry||process.env.REFRESH_TOKEN_EXPIRY||"7d",this.cookieOptions={httpOnly:!0,secure:"production"===process.env.NODE_ENV,sameSite:"strict",path:"/",maxAge:6048e5,...t?.cookieOptions},this.jwtOptions={algorithm:"HS512",issuer:"tokenly-auth",audience:"tokenly-client",...t?.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,...t?.rotationConfig},this.securityConfig={enableFingerprint:!0,enableBlacklist:!0,maxDevices:5,revokeOnSecurityBreach:!0,...t?.securityConfig},this.eventListeners=new Map,this.tokenCache=new Map}generateSecret(t){return e.createHash("sha256").update(`${this.instanceId}-${t}-${Date.now()}`).digest("hex")}formatDate(e){return new Date(1e3*e).toISOString()}decodeWithReadableDates(e,n){n||(n=t.decode(e));const{iat:r,exp:i,...s}=n;return{raw:e,payload:{...s,iat:r?this.formatDate(r):void 0,exp:i?this.formatDate(i):void 0}}}generateFingerprint(t){t?.userAgent?.trim()&&t?.ip?.trim()||s(n.INVALID_CONTEXT,"Invalid or empty context values");const r=t.userAgent.trim().toLowerCase().replace(/\s+/g," "),i=t.ip.trim().toLowerCase().replace(/[^0-9.]/g,""),o=`ua=${e.createHash("sha256").update(`ua:${this.instanceId}:${r}`).digest("hex")}|ip=${e.createHash("sha256").update(`ip:${this.instanceId}:${i}`).digest("hex")}`;return e.createHash("sha256").update(o).digest("hex")}revokeToken(e){if(e)try{const n=t.decode(e);this.revokedTokens.add(e),this.emit("tokenRevoked",{token:e,userId:n?.userId,timestamp:Date.now()})}catch(e){console.error("Error al revocar token:",e)}}isTokenBlacklisted(e){return this.securityConfig.enableBlacklist&&this.blacklistedTokens.has(e)}validatePayload(e){null!==e&&"object"==typeof e||s(n.INVALID_PAYLOAD),0===Object.keys(e).length&&s(n.EMPTY_PAYLOAD),Object.prototype.hasOwnProperty.call(e,"userId")||s(n.MISSING_USER_ID),null!==e.userId&&void 0!==e.userId||s(n.INVALID_USER_ID),"string"==typeof e.userId&&e.userId.trim()||s(n.INVALID_USER_ID),Object.entries(e).forEach((([e,t])=>{null==t&&s(n.INVALID_PAYLOAD,`Payload property '${e}' cannot be null or undefined`)}));JSON.stringify(e).length>8192&&s(n.INVALID_PAYLOAD,"Payload size exceeds maximum allowed size")}generateAccessToken(e,n,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=t.sign(i,this.secretAccess,{...this.jwtOptions,...n,expiresIn:this.accessTokenExpiry}),o=this.decodeWithReadableDates(s);return this.cacheToken(s,o),o}verifyAccessToken(e,i){this.revokedTokens.has(e)&&s(n.TOKEN_REVOKED),this.isTokenBlacklisted(e)&&s(n.TOKEN_REVOKED,"Token is blacklisted");try{const r=t.verify(e,this.secretAccess,{...this.verifyOptions,ignoreExpiration:!1,clockTolerance:0});if(this.securityConfig.enableFingerprint&&i){const e=this.generateFingerprint(i);r.fingerprint&&r.fingerprint!==e&&s(n.INVALID_FINGERPRINT)}const o=this.decodeWithReadableDates(e,r);return this.cacheToken(e,o),o}catch(e){if(e instanceof r)throw e;throw"TokenExpiredError"===e.name&&s(n.TOKEN_EXPIRED),"JsonWebTokenError"===e.name&&s(n.INVALID_TOKEN),e}}generateRefreshToken(e,n){this.validatePayload(e);const r={...e};delete r.aud,delete r.iss,delete r.exp,delete r.iat;const i=t.sign(r,this.secretRefresh,{...this.jwtOptions,expiresIn:this.refreshTokenExpiry}),s=this.decodeWithReadableDates(i);return s.cookieConfig={name:"refresh_token",value:i,options:{...this.cookieOptions,...n}},s}verifyRefreshToken(e){const n=t.verify(e,this.secretRefresh,this.verifyOptions);return this.decodeWithReadableDates(e,n)}rotateTokens(e){e&&"string"==typeof e||s(n.INVALID_TOKEN,"Invalid refresh token format");const t=this.verifyRefreshToken(e),{iat:r,exp:i,aud:o,iss:a,...c}=t.payload,h=e,l=this.rotationCounts.get(h)||0;l>=(this.rotationConfig.maxRotationCount||2)&&s(n.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,n=5){try{const r=t.decode(e);if(!r||!r.exp)return!1;const i=1e3*r.exp,s=Date.now();return i-s<60*n*1e3}catch{return!1}}getTokenInfo(e){try{const n=t.decode(e);return n?{userId:n.userId,expiresAt:new Date(1e3*n.exp),issuedAt:new Date(1e3*n.iat),fingerprint:n.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(n,r="5m"){const i={purpose:n,nonce:e.randomBytes(16).toString("hex"),iat:Math.floor(Date.now()/1e3)};return t.sign(i,this.secretAccess,{expiresIn:r})}verifyRefreshTokenEnhanced(e){this.validateTokenFormat(e)||s(n.INVALID_TOKEN,"Invalid token format");const t=this.verifyRefreshToken(e);return this.isTokenExpiringSoon(e,60)&&s(n.TOKEN_EXPIRED,"Refresh token is about to expire"),t}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 n=t.decode(e,{complete:!0});if(!n)throw new Error("Invalid token");return{algorithm:n.header.alg,hasFingerprint:!!n.payload.fingerprint,expirationTime:new Date(1e3*n.payload.exp),issuedAt:new Date(1e3*n.payload.iat),timeUntilExpiry:1e3*n.payload.exp-Date.now(),strength:this.calculateTokenStrength(n)}}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((([n,r])=>{try{const r=t.decode(n);if(r?.exp){const t=1e3*r.exp-Date.now();t<e&&this.emit("tokenExpiring",{token:n,userId:r.userId,expiresIn:t})}}catch(e){console.error("Error checking token expiration:",e)}}))}enableAutoCleanup(e=36e5){setInterval((()=>{const e=Date.now();this.revokedTokens.forEach((n=>{try{const r=t.decode(n);r&&r.exp&&1e3*r.exp<e&&this.revokedTokens.delete(n)}catch{this.revokedTokens.delete(n)}}))}),e)}handleDeviceStorage(e,t){this.deviceTokens.has(e)||this.deviceTokens.set(e,new Set);const r=this.deviceTokens.get(e),i=`${e}:${t}`;this.fingerprintCache.has(i)||(r.size>=this.securityConfig.maxDevices&&s(n.MAX_DEVICES_REACHED,{userId:e,currentDevices:r.size,maxDevices:this.securityConfig.maxDevices}),this.fingerprintCache.set(i,t)),r.add(t)}}function a(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}export{o as Tokenly,a as getClientIP}; //# sourceMappingURL=index.js.map