From 8bad06cf1dd4104e7d89e2b826707f06d68b2143 Mon Sep 17 00:00:00 2001 From: Alexis Mora Date: Wed, 29 Jul 2026 14:41:41 +0200 Subject: [PATCH 1/2] chore: rename validateJwt and export calculateRefreshThreshold --- .gitattributes | 1 + package-lock.json | 4 +-- package.json | 2 +- src/auth/checkTokenExpiration.ts | 12 ++++---- src/auth/index.ts | 5 +++- ...wtAndCheckExpiration.ts => validateJwt.ts} | 2 +- src/auth/validateTokenAndCheckExpiration.ts | 8 ++--- ...Expiration.test.ts => validateJwt.test.ts} | 30 +++++++++---------- 8 files changed, 34 insertions(+), 30 deletions(-) create mode 100644 .gitattributes rename src/auth/{validateJwtAndCheckExpiration.ts => validateJwt.ts} (91%) rename test/auth/{validateJwtAndCheckExpiration.test.ts => validateJwt.test.ts} (73%) diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..94f480d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 28f95aa..64f6213 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@internxt/lib", - "version": "1.5.0", + "version": "1.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@internxt/lib", - "version": "1.5.0", + "version": "1.5.2", "license": "MIT", "devDependencies": { "@internxt/eslint-config-internxt": "^2.1.0", diff --git a/package.json b/package.json index c1e77b1..16f4430 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@internxt/lib", - "version": "1.5.1", + "version": "1.5.2", "description": "Common logic shared between different projects of Internxt ", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/auth/checkTokenExpiration.ts b/src/auth/checkTokenExpiration.ts index 5d984f4..9c701d7 100644 --- a/src/auth/checkTokenExpiration.ts +++ b/src/auth/checkTokenExpiration.ts @@ -30,8 +30,7 @@ export function isTokenRefreshRequired(expirationTimestamp: number, issuedAtTime const currentTime = Math.floor(Date.now() / 1000); const remainingSeconds = expirationTimestamp - currentTime; - const refreshThreshold = - typeof issuedAtTimestamp === 'number' ? (expirationTimestamp - issuedAtTimestamp) / 2 : SIX_HOURS_IN_SECONDS; + const refreshThreshold = calculateRefreshThreshold(expirationTimestamp, issuedAtTimestamp); return remainingSeconds > 0 && remainingSeconds <= refreshThreshold; } @@ -43,10 +42,7 @@ export function isTokenRefreshRequired(expirationTimestamp: number, issuedAtTime * @param issuedAtTimestamp - Unix timestamp in seconds the token was issued at * @returns EXPIRED, REFRESH_REQUIRED, or VALID */ -export default function checkTokenExpiration( - expirationTimestamp: number, - issuedAtTimestamp?: number | null, -): TokenStatus { +export function checkTokenExpiration(expirationTimestamp: number, issuedAtTimestamp?: number | null): TokenStatus { if (isTokenExpired(expirationTimestamp)) { return TokenStatus.EXPIRED; } @@ -55,3 +51,7 @@ export default function checkTokenExpiration( } return TokenStatus.VALID; } + +export function calculateRefreshThreshold(expirationTimestamp: number, issuedAtTimestamp?: number | null): number { + return typeof issuedAtTimestamp === 'number' ? (expirationTimestamp - issuedAtTimestamp) / 2 : SIX_HOURS_IN_SECONDS; +} diff --git a/src/auth/index.ts b/src/auth/index.ts index 3f76969..722edd8 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -1,8 +1,9 @@ import isValidPassword from './isValidPassword'; import isValidEmail from './isValidEmail'; import testPasswordStrength from './testPasswordStrength'; -import checkTokenExpiration, { TokenStatus } from './checkTokenExpiration'; +import { TokenStatus, checkTokenExpiration, calculateRefreshThreshold } from './checkTokenExpiration'; import validateTokenAndCheckExpiration from './validateTokenAndCheckExpiration'; +import { validateJwt } from './validateJwt'; export default { isValidPassword, @@ -10,5 +11,7 @@ export default { testPasswordStrength, checkTokenExpiration, validateTokenAndCheckExpiration, + validateJwt, + calculateRefreshThreshold, TokenStatus, }; diff --git a/src/auth/validateJwtAndCheckExpiration.ts b/src/auth/validateJwt.ts similarity index 91% rename from src/auth/validateJwtAndCheckExpiration.ts rename to src/auth/validateJwt.ts index 77f1ccf..3a3b58a 100644 --- a/src/auth/validateJwtAndCheckExpiration.ts +++ b/src/auth/validateJwt.ts @@ -12,7 +12,7 @@ function decodeBase64UrlSegment(seg: string): string { * Does not verify signature or issuer. * @returns The exp and iat claims (iat is null if absent), or null if invalid structure */ -export default function validateJwtAndCheckExpiration(token: string): DecodedJwtClaims | null { +export function validateJwt(token: string): DecodedJwtClaims | null { if (typeof token !== 'string' || token.split('.').length !== 3) { return null; } diff --git a/src/auth/validateTokenAndCheckExpiration.ts b/src/auth/validateTokenAndCheckExpiration.ts index 48969a8..899d70a 100644 --- a/src/auth/validateTokenAndCheckExpiration.ts +++ b/src/auth/validateTokenAndCheckExpiration.ts @@ -1,13 +1,13 @@ -import validateJwtAndCheckExpiration from './validateJwtAndCheckExpiration'; -import checkTokenExpiration, { TokenStatus } from './checkTokenExpiration'; +import { validateJwt } from './validateJwt'; +import { TokenStatus, checkTokenExpiration } from './checkTokenExpiration'; /** * Combined validation and expiration check for convenience. - * For more granular control, use validateJwtAndCheckExpiration + checkTokenExpiration separately. + * For more granular control, use validateJwt + checkTokenExpiration separately. * @returns INVALID (malformed/unparseable token), EXPIRED, REFRESH_REQUIRED, or VALID */ export default function validateTokenAndCheckExpiration(token: string): TokenStatus { - const decoded = validateJwtAndCheckExpiration(token); + const decoded = validateJwt(token); if (!decoded) { return TokenStatus.INVALID; } diff --git a/test/auth/validateJwtAndCheckExpiration.test.ts b/test/auth/validateJwt.test.ts similarity index 73% rename from test/auth/validateJwtAndCheckExpiration.test.ts rename to test/auth/validateJwt.test.ts index 8c5093d..6ddb144 100644 --- a/test/auth/validateJwtAndCheckExpiration.test.ts +++ b/test/auth/validateJwt.test.ts @@ -1,62 +1,62 @@ import { describe, expect, test } from 'vitest'; -import validateJwtAndCheckExpiration from '../../src/auth/validateJwtAndCheckExpiration'; +import { validateJwt } from '../../src/auth/validateJwt'; -describe('validateJwtAndCheckExpiration tests', () => { +describe('validateJwt tests', () => { test('when the token is an empty string, then null is returned', () => { - expect(validateJwtAndCheckExpiration('')).to.be.equal(null); + expect(validateJwt('')).to.be.equal(null); }); test('when the token does not have the expected format, then null is returned', () => { - expect(validateJwtAndCheckExpiration('invalid')).to.be.equal(null); - expect(validateJwtAndCheckExpiration('invalid.token')).to.be.equal(null); + expect(validateJwt('invalid')).to.be.equal(null); + expect(validateJwt('invalid.token')).to.be.equal(null); }); test('when the token payload is not valid base64 encoding, then null is returned', () => { const invalidToken = 'eyJhbGciOiJIUzI1NiJ9.!!!invalid_base64!!!.signature'; - expect(validateJwtAndCheckExpiration(invalidToken)).to.be.equal(null); + expect(validateJwt(invalidToken)).to.be.equal(null); }); test('when the token header is not valid JSON, then null is returned', () => { const token = 'invalid-json.eyJzdWIiOiJ1c2VyMTIzIn0=.signature'; - expect(validateJwtAndCheckExpiration(token)).to.be.equal(null); + expect(validateJwt(token)).to.be.equal(null); }); test('when the token header is missing the alg field, then null is returned', () => { const header = Buffer.from(JSON.stringify({ typ: 'JWT' })).toString('base64'); const payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600 })).toString('base64'); const token = `${header}.${payload}.signature`; - expect(validateJwtAndCheckExpiration(token)).to.be.equal(null); + expect(validateJwt(token)).to.be.equal(null); }); test('when the token has a negative expiration value, then null is returned', () => { const payload = Buffer.from(JSON.stringify({ exp: -1 })).toString('base64'); const token = `eyJhbGciOiJIUzI1NiJ9.${payload}.signature`; - expect(validateJwtAndCheckExpiration(token)).to.be.equal(null); + expect(validateJwt(token)).to.be.equal(null); }); test('when the token does not contain an expiration claim, then null is returned', () => { const payload = Buffer.from(JSON.stringify({ sub: 'user123' })).toString('base64'); const token = `eyJhbGciOiJIUzI1NiJ9.${payload}.signature`; - expect(validateJwtAndCheckExpiration(token)).to.be.equal(null); + expect(validateJwt(token)).to.be.equal(null); }); test('when the token expiration value is not a number, then null is returned', () => { const payload = Buffer.from(JSON.stringify({ exp: 'not-a-number' })).toString('base64'); const token = `eyJhbGciOiJIUzI1NiJ9.${payload}.signature`; - expect(validateJwtAndCheckExpiration(token)).to.be.equal(null); + expect(validateJwt(token)).to.be.equal(null); }); test('when the token has a valid structure with expiration, then the exp and iat claims are returned', () => { const expiration = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now const payload = Buffer.from(JSON.stringify({ exp: expiration, sub: 'user123' })).toString('base64'); const token = `eyJhbGciOiJIUzI1NiJ9.${payload}.signature`; - expect(validateJwtAndCheckExpiration(token)).to.deep.equal({ + expect(validateJwt(token)).to.deep.equal({ exp: expiration, iat: null, }); }); - test('when the token payload is base64url-encoded with characters outside the standard alphabet, then the claims are returned', () => { + test('when payload is base64url with characters outside the standard alphabet, then the claims are returned', () => { const issuedAt = 1577836800; const expiration = 4102444800; // Payload encodes to a segment containing both - and _ (base64url alphabet) @@ -66,7 +66,7 @@ describe('validateJwtAndCheckExpiration tests', () => { expect(payload).to.match(/-/); expect(payload).to.match(/_/); const token = `eyJhbGciOiJIUzI1NiJ9.${payload}.signature`; - expect(validateJwtAndCheckExpiration(token)).to.deep.equal({ + expect(validateJwt(token)).to.deep.equal({ exp: expiration, iat: issuedAt, }); @@ -77,7 +77,7 @@ describe('validateJwtAndCheckExpiration tests', () => { const expiration = issuedAt + 3600; // 1 hour from now const payload = Buffer.from(JSON.stringify({ exp: expiration, iat: issuedAt, sub: 'user123' })).toString('base64'); const token = `eyJhbGciOiJIUzI1NiJ9.${payload}.signature`; - expect(validateJwtAndCheckExpiration(token)).to.deep.equal({ + expect(validateJwt(token)).to.deep.equal({ exp: expiration, iat: issuedAt, }); From 4eb06c5b05e206fda231177700c2209c44044b79 Mon Sep 17 00:00:00 2001 From: Alexis Mora Date: Thu, 30 Jul 2026 16:30:03 +0200 Subject: [PATCH 2/2] feat: calculateMillisecondsUntilRefresh --- src/auth/checkTokenExpiration.ts | 11 +++++- src/auth/index.ts | 4 +- test/auth/checkTokenExpiration.test.ts | 51 +++++++++++++++++++++++++- 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/auth/checkTokenExpiration.ts b/src/auth/checkTokenExpiration.ts index 9c701d7..cc256e7 100644 --- a/src/auth/checkTokenExpiration.ts +++ b/src/auth/checkTokenExpiration.ts @@ -52,6 +52,15 @@ export function checkTokenExpiration(expirationTimestamp: number, issuedAtTimest return TokenStatus.VALID; } -export function calculateRefreshThreshold(expirationTimestamp: number, issuedAtTimestamp?: number | null): number { +export function calculateMillisecondsUntilRefresh( + expirationTimestamp: number, + issuedAtTimestamp?: number | null, +): number { + const currentTime = Math.floor(Date.now() / 1000); + const refreshThreshold = calculateRefreshThreshold(expirationTimestamp, issuedAtTimestamp); + return Math.max(0, (expirationTimestamp - currentTime - refreshThreshold) * 1000); +} + +function calculateRefreshThreshold(expirationTimestamp: number, issuedAtTimestamp?: number | null): number { return typeof issuedAtTimestamp === 'number' ? (expirationTimestamp - issuedAtTimestamp) / 2 : SIX_HOURS_IN_SECONDS; } diff --git a/src/auth/index.ts b/src/auth/index.ts index 722edd8..ae23979 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -1,7 +1,7 @@ import isValidPassword from './isValidPassword'; import isValidEmail from './isValidEmail'; import testPasswordStrength from './testPasswordStrength'; -import { TokenStatus, checkTokenExpiration, calculateRefreshThreshold } from './checkTokenExpiration'; +import { TokenStatus, checkTokenExpiration, calculateMillisecondsUntilRefresh } from './checkTokenExpiration'; import validateTokenAndCheckExpiration from './validateTokenAndCheckExpiration'; import { validateJwt } from './validateJwt'; @@ -12,6 +12,6 @@ export default { checkTokenExpiration, validateTokenAndCheckExpiration, validateJwt, - calculateRefreshThreshold, + calculateMillisecondsUntilRefresh, TokenStatus, }; diff --git a/test/auth/checkTokenExpiration.test.ts b/test/auth/checkTokenExpiration.test.ts index 10e911a..75731ff 100644 --- a/test/auth/checkTokenExpiration.test.ts +++ b/test/auth/checkTokenExpiration.test.ts @@ -1,7 +1,7 @@ -import { describe, expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; import { auth } from '../../src'; -const { checkTokenExpiration, TokenStatus } = auth; +const { calculateMillisecondsUntilRefresh, checkTokenExpiration, TokenStatus } = auth; describe('checkTokenExpiration tests', () => { test('when the token expired more than two days ago, then it is EXPIRED', () => { @@ -67,4 +67,51 @@ describe('checkTokenExpiration tests', () => { expect(checkTokenExpiration(expiration, issuedAt)).to.be.equal(TokenStatus.VALID); }); }); + + describe('calculateMillisecondsUntilRefresh', () => { + const SECOND_IN_MILLISECONDS = 1_000; + const HOUR_IN_SECONDS = 60 * 60; + const DAY_IN_HOURS = 24; + const SIX_HOURS = 6; + const TWELVE_HOURS = 12; + const TEST_DATE = new Date('2026-01-01T00:00:00.000Z'); + test('returns the time until halfway through the token lifetime when iat is present', () => { + vi.useFakeTimers(); + vi.setSystemTime(TEST_DATE); + + const issuedAt = Math.floor(Date.now() / SECOND_IN_MILLISECONDS); + const expiration = issuedAt + DAY_IN_HOURS * HOUR_IN_SECONDS; + + expect(calculateMillisecondsUntilRefresh(expiration, issuedAt)).toBe( + TWELVE_HOURS * HOUR_IN_SECONDS * SECOND_IN_MILLISECONDS, + ); + + vi.useRealTimers(); + }); + + test('uses the six-hour threshold when iat is missing', () => { + vi.useFakeTimers(); + vi.setSystemTime(TEST_DATE); + + const expiration = Math.floor(Date.now() / 1000) + DAY_IN_HOURS * HOUR_IN_SECONDS; + + expect(calculateMillisecondsUntilRefresh(expiration)).toBe( + (DAY_IN_HOURS - SIX_HOURS) * HOUR_IN_SECONDS * SECOND_IN_MILLISECONDS, + ); + + vi.useRealTimers(); + }); + + test('returns zero when the refresh threshold has already passed', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(TEST_DATE.getTime() + TWELVE_HOURS * HOUR_IN_SECONDS * SECOND_IN_MILLISECONDS)); + + const issuedAt = Math.floor(TEST_DATE.getTime() / SECOND_IN_MILLISECONDS); + const expiration = issuedAt + TWELVE_HOURS * HOUR_IN_SECONDS; + + expect(calculateMillisecondsUntilRefresh(expiration, issuedAt)).toBe(0); + + vi.useRealTimers(); + }); + }); });