Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* text=auto eol=lf
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
21 changes: 15 additions & 6 deletions src/auth/checkTokenExpiration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -55,3 +51,16 @@ export default function checkTokenExpiration(
}
return TokenStatus.VALID;
}

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;
}
5 changes: 4 additions & 1 deletion src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import isValidPassword from './isValidPassword';
import isValidEmail from './isValidEmail';
import testPasswordStrength from './testPasswordStrength';
import checkTokenExpiration, { TokenStatus } from './checkTokenExpiration';
import { TokenStatus, checkTokenExpiration, calculateMillisecondsUntilRefresh } from './checkTokenExpiration';
import validateTokenAndCheckExpiration from './validateTokenAndCheckExpiration';
import { validateJwt } from './validateJwt';

export default {
isValidPassword,
isValidEmail,
testPasswordStrength,
checkTokenExpiration,
validateTokenAndCheckExpiration,
validateJwt,
calculateMillisecondsUntilRefresh,
TokenStatus,
};
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
8 changes: 4 additions & 4 deletions src/auth/validateTokenAndCheckExpiration.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down
51 changes: 49 additions & 2 deletions test/auth/checkTokenExpiration.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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();
});
});
});
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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,
});
Expand All @@ -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,
});
Expand Down