|
1 | 1 | import { describe, expect, test, vi } from 'vitest'; |
2 | | -import { auth } from '@internxt/lib'; |
| 2 | +import { auth, TokenStatus } from '@internxt/lib'; |
3 | 3 | import { randomInt, randomUUID } from 'node:crypto'; |
4 | 4 | import { UserFixture } from '../fixtures/auth.fixture'; |
5 | 5 | import { ValidationService } from '../../src/services/validation.service'; |
@@ -69,4 +69,82 @@ describe('Validation Service', () => { |
69 | 69 | expect(ValidationService.instance.validateStringIsNotEmpty('\t')).to.be.equal(false); |
70 | 70 | expect(ValidationService.instance.validateStringIsNotEmpty('\t\n')).to.be.equal(false); |
71 | 71 | }); |
| 72 | + |
| 73 | + describe('validateTokenAndCheckExpiration', () => { |
| 74 | + const nowInSeconds = () => Math.floor(Date.now() / 1000); |
| 75 | + const ONE_HOUR_IN_SECONDS = 60 * 60; |
| 76 | + // The library decodes the payload with Buffer.from(..., 'base64') which |
| 77 | + // handles both standard and URL-safe base64 after character substitution. |
| 78 | + const encodeSegment = (payload: object): string => Buffer.from(JSON.stringify(payload)).toString('base64'); |
| 79 | + const createToken = (payload: object): string => |
| 80 | + [encodeSegment({ alg: 'RS256', typ: 'JWT' }), encodeSegment(payload), 'signature'].join('.'); |
| 81 | + |
| 82 | + test('when a token expires far in the future and most of its lifetime remains, then it is VALID', () => { |
| 83 | + const token = createToken({ exp: nowInSeconds() + 24 * ONE_HOUR_IN_SECONDS, iat: nowInSeconds() }); |
| 84 | + expect(ValidationService.instance.validateTokenAndCheckExpiration(token)).to.be.equal(TokenStatus.VALID); |
| 85 | + }); |
| 86 | + |
| 87 | + test('when more than half of the token lifetime has elapsed, then it is REFRESH_REQUIRED', () => { |
| 88 | + const token = createToken({ |
| 89 | + exp: nowInSeconds() + ONE_HOUR_IN_SECONDS, |
| 90 | + iat: nowInSeconds() - 2 * ONE_HOUR_IN_SECONDS, |
| 91 | + }); |
| 92 | + expect(ValidationService.instance.validateTokenAndCheckExpiration(token)).to.be.equal( |
| 93 | + TokenStatus.REFRESH_REQUIRED, |
| 94 | + ); |
| 95 | + }); |
| 96 | + |
| 97 | + test('when the expiration timestamp is in the past, then it is EXPIRED', () => { |
| 98 | + const token = createToken({ |
| 99 | + exp: nowInSeconds() - ONE_HOUR_IN_SECONDS, |
| 100 | + iat: nowInSeconds() - 3 * ONE_HOUR_IN_SECONDS, |
| 101 | + }); |
| 102 | + expect(ValidationService.instance.validateTokenAndCheckExpiration(token)).to.be.equal(TokenStatus.EXPIRED); |
| 103 | + }); |
| 104 | + |
| 105 | + test('when the token has no iat claim, then a fixed six-hour refresh margin applies', () => { |
| 106 | + const farFromExpiring = createToken({ exp: nowInSeconds() + 7 * ONE_HOUR_IN_SECONDS }); |
| 107 | + expect(ValidationService.instance.validateTokenAndCheckExpiration(farFromExpiring)).to.be.equal( |
| 108 | + TokenStatus.VALID, |
| 109 | + ); |
| 110 | + |
| 111 | + const closeToExpiring = createToken({ exp: nowInSeconds() + 5 * ONE_HOUR_IN_SECONDS }); |
| 112 | + expect(ValidationService.instance.validateTokenAndCheckExpiration(closeToExpiring)).to.be.equal( |
| 113 | + TokenStatus.REFRESH_REQUIRED, |
| 114 | + ); |
| 115 | + }); |
| 116 | + |
| 117 | + test('when a token is malformed or unparseable, then it is INVALID', () => { |
| 118 | + expect(ValidationService.instance.validateTokenAndCheckExpiration('')).to.be.equal(TokenStatus.INVALID); |
| 119 | + expect(ValidationService.instance.validateTokenAndCheckExpiration('not-a-token')).to.be.equal( |
| 120 | + TokenStatus.INVALID, |
| 121 | + ); |
| 122 | + expect(ValidationService.instance.validateTokenAndCheckExpiration('only.twosegments')).to.be.equal( |
| 123 | + TokenStatus.INVALID, |
| 124 | + ); |
| 125 | + expect( |
| 126 | + ValidationService.instance.validateTokenAndCheckExpiration('header.!!!not-base64!!!.signature'), |
| 127 | + ).to.be.equal(TokenStatus.INVALID); |
| 128 | + const notJsonPayload = ['header', Buffer.from('plain text').toString('base64'), 'signature'].join('.'); |
| 129 | + expect(ValidationService.instance.validateTokenAndCheckExpiration(notJsonPayload)).to.be.equal( |
| 130 | + TokenStatus.INVALID, |
| 131 | + ); |
| 132 | + }); |
| 133 | + |
| 134 | + test('when the payload has no numeric exp claim, then it is INVALID', () => { |
| 135 | + const missingExp = createToken({ iat: nowInSeconds() }); |
| 136 | + expect(ValidationService.instance.validateTokenAndCheckExpiration(missingExp)).to.be.equal(TokenStatus.INVALID); |
| 137 | + |
| 138 | + const nonNumericExp = createToken({ exp: 'tomorrow', iat: nowInSeconds() }); |
| 139 | + expect(ValidationService.instance.validateTokenAndCheckExpiration(nonNumericExp)).to.be.equal( |
| 140 | + TokenStatus.INVALID, |
| 141 | + ); |
| 142 | + }); |
| 143 | + |
| 144 | + test('when the payload encoding contains base64url-specific characters, then it is correctly decoded', () => { |
| 145 | + const base64UrlToken = |
| 146 | + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjQxMDI0NDQ4MDAsImlhdCI6MTU3NzgzNjgwMCwic3ViIjoiw7_DvsO9In0.signature'; |
| 147 | + expect(ValidationService.instance.validateTokenAndCheckExpiration(base64UrlToken)).to.be.equal(TokenStatus.VALID); |
| 148 | + }); |
| 149 | + }); |
72 | 150 | }); |
0 commit comments