diff --git a/README.md b/README.md new file mode 100644 index 0000000..7381697 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# @internxt/lib + +Common logic shared between different projects of Internxt. + +A zero-dependency TypeScript library providing utilities for AES-256-GCM encryption, authentication helpers (email/password validation, JWT token handling), file/item naming, and string manipulation. + +## Installation + +```sh +npm install @internxt/lib +``` + +## Development + +```sh +# Install dependencies +npm install + +# Run tests +npm run test + +# Build +npm run build +``` + +## License + +[MIT](LICENSE) diff --git a/package.json b/package.json index e62fdde..c1e77b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@internxt/lib", - "version": "1.5.0", + "version": "1.5.1", "description": "Common logic shared between different projects of Internxt ", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/auth/validateJwtAndCheckExpiration.ts b/src/auth/validateJwtAndCheckExpiration.ts index d5be35f..77f1ccf 100644 --- a/src/auth/validateJwtAndCheckExpiration.ts +++ b/src/auth/validateJwtAndCheckExpiration.ts @@ -3,8 +3,12 @@ export interface DecodedJwtClaims { iat: number | null; } +function decodeBase64UrlSegment(seg: string): string { + return Buffer.from(seg.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString(); +} + /** - * Validates JWT token structure and parses the expiration and issued-at claims. + * Validates JWT token structure, header (presence of alg), and parses the expiration and issued-at claims. * Does not verify signature or issuer. * @returns The exp and iat claims (iat is null if absent), or null if invalid structure */ @@ -14,10 +18,18 @@ export default function validateJwtAndCheckExpiration(token: string): DecodedJwt } try { - const payload = JSON.parse(atob(token.split('.')[1])); - if (typeof payload.exp !== 'number') { + const [headerSeg, payloadSeg] = token.split('.'); + + const header = JSON.parse(decodeBase64UrlSegment(headerSeg)); + if (typeof header.alg !== 'string') { return null; } + + const payload = JSON.parse(decodeBase64UrlSegment(payloadSeg)); + if (typeof payload.exp !== 'number' || payload.exp < 0) { + return null; + } + return { exp: payload.exp, iat: typeof payload.iat === 'number' ? payload.iat : null, diff --git a/test/auth/validateJwtAndCheckExpiration.test.ts b/test/auth/validateJwtAndCheckExpiration.test.ts index 2e7a6b8..8c5093d 100644 --- a/test/auth/validateJwtAndCheckExpiration.test.ts +++ b/test/auth/validateJwtAndCheckExpiration.test.ts @@ -12,37 +12,71 @@ describe('validateJwtAndCheckExpiration tests', () => { }); test('when the token payload is not valid base64 encoding, then null is returned', () => { - const invalidToken = 'header.!!!invalid_base64!!!.signature'; + const invalidToken = 'eyJhbGciOiJIUzI1NiJ9.!!!invalid_base64!!!.signature'; expect(validateJwtAndCheckExpiration(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); + }); + + 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); + }); + + 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); + }); + test('when the token does not contain an expiration claim, then null is returned', () => { - const payload = btoa(JSON.stringify({ sub: 'user123' })); - const token = `header.${payload}.signature`; + const payload = Buffer.from(JSON.stringify({ sub: 'user123' })).toString('base64'); + const token = `eyJhbGciOiJIUzI1NiJ9.${payload}.signature`; expect(validateJwtAndCheckExpiration(token)).to.be.equal(null); }); test('when the token expiration value is not a number, then null is returned', () => { - const payload = btoa(JSON.stringify({ exp: 'not-a-number' })); - const token = `header.${payload}.signature`; + const payload = Buffer.from(JSON.stringify({ exp: 'not-a-number' })).toString('base64'); + const token = `eyJhbGciOiJIUzI1NiJ9.${payload}.signature`; expect(validateJwtAndCheckExpiration(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 = btoa(JSON.stringify({ exp: expiration, sub: 'user123' })); - const token = `header.${payload}.signature`; + const payload = Buffer.from(JSON.stringify({ exp: expiration, sub: 'user123' })).toString('base64'); + const token = `eyJhbGciOiJIUzI1NiJ9.${payload}.signature`; expect(validateJwtAndCheckExpiration(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', () => { + const issuedAt = 1577836800; + const expiration = 4102444800; + // Payload encodes to a segment containing both - and _ (base64url alphabet) + const payload = Buffer.from(JSON.stringify({ exp: expiration, iat: issuedAt, sub: 'WO¥þ=(E' })).toString( + 'base64url', + ); + expect(payload).to.match(/-/); + expect(payload).to.match(/_/); + const token = `eyJhbGciOiJIUzI1NiJ9.${payload}.signature`; + expect(validateJwtAndCheckExpiration(token)).to.deep.equal({ + exp: expiration, + iat: issuedAt, + }); + }); + test('when the token has a valid structure with expiration and issued-at, then both claims are returned', () => { const issuedAt = Math.floor(Date.now() / 1000); const expiration = issuedAt + 3600; // 1 hour from now - const payload = btoa(JSON.stringify({ exp: expiration, iat: issuedAt, sub: 'user123' })); - const token = `header.${payload}.signature`; + 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({ exp: expiration, iat: issuedAt, diff --git a/test/auth/validateTokenAndCheckExpiration.test.ts b/test/auth/validateTokenAndCheckExpiration.test.ts index b4a6c98..53ab329 100644 --- a/test/auth/validateTokenAndCheckExpiration.test.ts +++ b/test/auth/validateTokenAndCheckExpiration.test.ts @@ -10,16 +10,16 @@ describe('validateTokenAndCheckExpiration tests', () => { test('when the token is structurally valid but has expired, then EXPIRED is returned', () => { const expiration = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago - const payload = btoa(JSON.stringify({ exp: expiration })); - const token = `header.${payload}.signature`; + const payload = Buffer.from(JSON.stringify({ exp: expiration })).toString('base64'); + const token = `eyJhbGciOiJIUzI1NiJ9.${payload}.signature`; expect(validateTokenAndCheckExpiration(token)).to.be.equal(TokenStatus.EXPIRED); }); test('when the token is valid and expires within six hours, then REFRESH_REQUIRED is returned', () => { const expiration = Math.floor(Date.now() / 1000) + 6 * 60 * 60; // 6 hours from now - const payload = btoa(JSON.stringify({ exp: expiration })); - const token = `header.${payload}.signature`; + const payload = Buffer.from(JSON.stringify({ exp: expiration })).toString('base64'); + const token = `eyJhbGciOiJIUzI1NiJ9.${payload}.signature`; expect(validateTokenAndCheckExpiration(token)).to.be.equal(TokenStatus.REFRESH_REQUIRED); }); @@ -27,16 +27,16 @@ describe('validateTokenAndCheckExpiration tests', () => { test('when the token is valid, has an issued-at claim, and is past 50% of its lifetime, then REFRESH_REQUIRED is returned', () => { const issuedAt = Math.floor(Date.now() / 1000) - 16 * 60 * 60; // issued 16h ago const expiration = issuedAt + 32 * 60 * 60; // total lifetime 32h, 16h (50%) remaining - const payload = btoa(JSON.stringify({ exp: expiration, iat: issuedAt })); - const token = `header.${payload}.signature`; + const payload = Buffer.from(JSON.stringify({ exp: expiration, iat: issuedAt })).toString('base64'); + const token = `eyJhbGciOiJIUzI1NiJ9.${payload}.signature`; expect(validateTokenAndCheckExpiration(token)).to.be.equal(TokenStatus.REFRESH_REQUIRED); }); test('when the token is valid and expires in thirty days, then VALID is returned', () => { const expiration = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60; // 30 days from now - const payload = btoa(JSON.stringify({ exp: expiration })); - const token = `header.${payload}.signature`; + const payload = Buffer.from(JSON.stringify({ exp: expiration })).toString('base64'); + const token = `eyJhbGciOiJIUzI1NiJ9.${payload}.signature`; expect(validateTokenAndCheckExpiration(token)).to.be.equal(TokenStatus.VALID); }); diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..ffac201 --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "types": ["node"] + }, + "include": ["./**/*.ts"] +}