diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 0148c6a0..faf231c0 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -9,6 +9,7 @@ import { MissingCredentialsError, } from '../types/command.types'; import { ValidationService } from './validation.service'; +import { isOldTokenError, OldTokenDetectedError } from '../utils/errors.utils'; export class AuthService { public static readonly instance: AuthService = new AuthService(); @@ -62,9 +63,13 @@ export class AuthService { * Checks and returns the user auth details (it refreshes the tokens if needed) * * @returns The user details and the auth tokens + * @throws {MissingCredentialsError} When user credentials are not found + * @throws {InvalidCredentialsError} When token or mnemonic is invalid + * @throws {ExpiredCredentialsError} When token has expired + * @throws {OldTokenDetectedError} When old token is detected (user is logged out automatically) */ public getAuthDetails = async (): Promise => { - let loginCreds = await ConfigService.instance.readUser(); + const loginCreds = await ConfigService.instance.readUser(); if (!loginCreds?.token || !loginCreds?.user?.mnemonic) { throw new MissingCredentialsError(); } @@ -79,18 +84,25 @@ export class AuthService { throw new ExpiredCredentialsError(); } - const refreshToken = tokenDetails.expiration.refreshRequired; - if (refreshToken) { - loginCreds = await this.refreshUserToken(loginCreds.token, loginCreds.user.mnemonic); + if (!tokenDetails.expiration.refreshRequired) { + return loginCreds; + } + try { + return await this.refreshUserToken(loginCreds.token, loginCreds.user.mnemonic); + } catch (error) { + if (isOldTokenError(error)) { + await ConfigService.instance.clearUser(); + throw new OldTokenDetectedError(); + } + throw error; } - - return loginCreds; }; /** * Refreshes the user tokens and stores them in the credentials file * * @returns The user details and the renewed auth token + * @throws {InvalidCredentialsError} When the mnemonic is invalid */ public refreshUserToken = async (oldToken: string, mnemonic: string): Promise => { SdkManager.init({ token: oldToken }); diff --git a/src/services/config.service.ts b/src/services/config.service.ts index d6dca41d..a9a46000 100644 --- a/src/services/config.service.ts +++ b/src/services/config.service.ts @@ -4,6 +4,7 @@ import fs from 'node:fs/promises'; import { ConfigKeys } from '../types/config.types'; import { LoginCredentials, WebdavConfig } from '../types/command.types'; import { CryptoService } from './crypto.service'; +import { isFileNotFoundError } from '../utils/errors.utils'; export class ConfigService { static readonly INTERNXT_CLI_DATA_DIR = path.join(os.homedir(), '.internxt-cli'); @@ -49,12 +50,16 @@ export class ConfigService { * @async **/ public clearUser = async (): Promise => { - const stat = await fs.stat(ConfigService.CREDENTIALS_FILE); - - if (stat.size === 0) throw new Error('Credentials file is already empty'); - return fs.writeFile(ConfigService.CREDENTIALS_FILE, '', 'utf8'); + try { + const stat = await fs.stat(ConfigService.CREDENTIALS_FILE); + if (stat.size === 0) return; + await fs.writeFile(ConfigService.CREDENTIALS_FILE, '', 'utf8'); + } catch (error) { + if (!isFileNotFoundError(error)) { + throw error; + } + } }; - /** * Returns the authenticated user credentials * @returns {CLICredentials} The authenticated user credentials diff --git a/src/services/validation.service.ts b/src/services/validation.service.ts index f8226b7c..8012f94a 100644 --- a/src/services/validation.service.ts +++ b/src/services/validation.service.ts @@ -43,6 +43,50 @@ export class ValidationService { return fileStat.isFile(); }; + /** + * Validates JWT token structure and parses the expiration claim. + * Does not verify signature or issuer. + * @returns Expiration timestamp in seconds, or null if invalid structure + */ + public validateJwtAndCheckExpiration = (token?: string): number | null => { + if (!token || typeof token !== 'string' || token.split('.').length !== 3) { + return null; + } + + try { + const payload = JSON.parse(atob(token.split('.')[1])); + return typeof payload.exp === 'number' ? payload.exp : null; + } catch { + return null; + } + }; + + /** + * Checks token expiration status. + * @param expirationTimestamp - Unix timestamp in seconds + * @returns Object indicating if token is expired or needs refresh (within 2 days) + */ + public checkTokenExpiration = ( + expirationTimestamp: number, + ): { + expired: boolean; + refreshRequired: boolean; + } => { + const TWO_DAYS_IN_SECONDS = 2 * 24 * 60 * 60; + const currentTime = Math.floor(Date.now() / 1000); + const remainingSeconds = expirationTimestamp - currentTime; + + return { + expired: remainingSeconds <= 0, + refreshRequired: remainingSeconds > 0 && remainingSeconds <= TWO_DAYS_IN_SECONDS, + }; + }; + + /** + * Combined validation and expiration check for convenience. + * For the original combined behavior, use this method. + * For more granular control, use parseJwtExpiration + checkTokenExpiration separately. + */ public validateTokenAndCheckExpiration = ( token?: string, ): { @@ -52,31 +96,11 @@ export class ValidationService { refreshRequired: boolean; }; } => { - if (!token || typeof token !== 'string') { - return { isValid: false, expiration: { expired: true, refreshRequired: false } }; - } - - const parts = token.split('.'); - if (parts.length !== 3) { - return { isValid: false, expiration: { expired: true, refreshRequired: false } }; - } - - try { - const payload = JSON.parse(atob(parts[1])); - if (typeof payload.exp !== 'number') { - return { isValid: false, expiration: { expired: true, refreshRequired: false } }; - } - - const currentTime = Math.floor(Date.now() / 1000); - const twoDaysInSeconds = 2 * 24 * 60 * 60; - const remainingSeconds = payload.exp - currentTime; - - const expired = remainingSeconds <= 0; - const refreshRequired = remainingSeconds > 0 && remainingSeconds <= twoDaysInSeconds; - - return { isValid: true, expiration: { expired, refreshRequired } }; - } catch { - return { isValid: false, expiration: { expired: true, refreshRequired: false } }; - } + const expiration = this.validateJwtAndCheckExpiration(token); + return { + isValid: expiration !== null, + expiration: + expiration !== null ? this.checkTokenExpiration(expiration) : { expired: true, refreshRequired: false }, + }; }; } diff --git a/src/utils/errors.utils.ts b/src/utils/errors.utils.ts index b58ddbdc..5869f088 100644 --- a/src/utils/errors.utils.ts +++ b/src/utils/errors.utils.ts @@ -11,6 +11,15 @@ export function isAlreadyExistsError(error: unknown): error is Error { (typeof error === 'object' && error !== null && 'status' in error && error.status === 409) ); } + +export function isOldTokenError(error: unknown): boolean { + return isError(error) && error.message.toLowerCase().includes('old token version detected'); +} + +export function isFileNotFoundError(error: unknown): error is NodeJS.ErrnoException { + return isError(error) && 'code' in error && error.code === 'ENOENT'; +} + export class ErrorUtils { static report(error: unknown, props: Record = {}) { if (isError(error)) { @@ -82,3 +91,11 @@ export class NotImplementedError extends Error { Object.setPrototypeOf(this, NotImplementedError.prototype); } } + +export class OldTokenDetectedError extends Error { + constructor() { + super('Old token detected, credentials cleared. Please login again.'); + this.name = 'OldTokenDetectedError'; + Object.setPrototypeOf(this, OldTokenDetectedError.prototype); + } +} diff --git a/test/services/auth.service.test.ts b/test/services/auth.service.test.ts index c228ebbd..a8010ee6 100644 --- a/test/services/auth.service.test.ts +++ b/test/services/auth.service.test.ts @@ -236,4 +236,65 @@ describe('Auth service', () => { expect(validateMnemonicStub).toHaveBeenCalledWith(UserCredentialsFixture.user.mnemonic); expect(refreshTokensStub).toHaveBeenCalledOnce(); }); + + it('should clear and throw exception when old token is detected during token refresh', async () => { + const sut = AuthService.instance; + + const mockToken = { + isValid: true, + expiration: { + expired: false, + refreshRequired: true, + }, + }; + + const oldTokenError = new Error('Old token version detected'); + + vi.spyOn(ConfigService.instance, 'readUser').mockResolvedValue(UserCredentialsFixture); + vi.spyOn(ValidationService.instance, 'validateTokenAndCheckExpiration').mockImplementationOnce(() => mockToken); + vi.spyOn(ValidationService.instance, 'validateMnemonic').mockReturnValue(true); + const refreshTokenStub = vi.spyOn(sut, 'refreshUserToken').mockRejectedValue(oldTokenError); + const clearUserStub = vi.spyOn(ConfigService.instance, 'clearUser').mockResolvedValue(); + + try { + await sut.getAuthDetails(); + fail('Expected function to throw an error, but it did not.'); + } catch (error) { + expect((error as Error).name).to.be.equal('OldTokenDetectedError'); + expect((error as Error).message).to.include('Old token detected'); + } + + expect(refreshTokenStub).toHaveBeenCalledOnce(); + expect(clearUserStub).toHaveBeenCalledOnce(); + }); + + it('should rethrow error if token refresh fails for reasons other than old token detection', async () => { + const sut = AuthService.instance; + + const mockToken = { + isValid: true, + expiration: { + expired: false, + refreshRequired: true, + }, + }; + + const networkError = new Error('Network timeout'); + + vi.spyOn(ConfigService.instance, 'readUser').mockResolvedValue(UserCredentialsFixture); + vi.spyOn(ValidationService.instance, 'validateTokenAndCheckExpiration').mockImplementationOnce(() => mockToken); + vi.spyOn(ValidationService.instance, 'validateMnemonic').mockReturnValue(true); + const refreshTokenStub = vi.spyOn(sut, 'refreshUserToken').mockRejectedValue(networkError); + const clearUserStub = vi.spyOn(ConfigService.instance, 'clearUser').mockResolvedValue(); + + try { + await sut.getAuthDetails(); + fail('Expected function to throw an error, but it did not.'); + } catch (error) { + expect((error as Error).message).to.be.equal('Network timeout'); + } + + expect(refreshTokenStub).toHaveBeenCalledOnce(); + expect(clearUserStub).not.toHaveBeenCalled(); + }); }); diff --git a/test/services/config.service.test.ts b/test/services/config.service.test.ts index 1a3a2cc2..c3b64a82 100644 --- a/test/services/config.service.test.ts +++ b/test/services/config.service.test.ts @@ -91,17 +91,30 @@ describe('Config service', () => { expect(credentialsFileContent).to.be.equal(''); }); - it('When user credentials are cleared and the file is empty, then an error is thrown', async () => { - vi.spyOn(fs, 'stat') + it('should not throw exception when user credentials are cleared and the file is already empty', async () => { + const statStub = vi + .spyOn(fs, 'stat') // @ts-expect-error - We stub the stat method partially .mockResolvedValue({ size: 0 }); + const writeFileStub = vi.spyOn(fs, 'writeFile').mockResolvedValue(); - try { - await ConfigService.instance.clearUser(); - fail('Expected function to throw an error, but it did not.'); - } catch (error) { - expect((error as Error).message).to.be.equal('Credentials file is already empty'); - } + await ConfigService.instance.clearUser(); + + expect(statStub).toHaveBeenCalledWith(ConfigService.CREDENTIALS_FILE); + expect(writeFileStub).not.toHaveBeenCalled(); + }); + + it('should not throw exception when user credentials are cleared and the file does not exist', async () => { + const fileNotFoundError = new Error('File not found'); + Object.assign(fileNotFoundError, { code: 'ENOENT' }); + + const statStub = vi.spyOn(fs, 'stat').mockRejectedValue(fileNotFoundError); + const writeFileStub = vi.spyOn(fs, 'writeFile').mockResolvedValue(); + + await ConfigService.instance.clearUser(); + + expect(statStub).toHaveBeenCalledWith(ConfigService.CREDENTIALS_FILE); + expect(writeFileStub).not.toHaveBeenCalled(); }); it('When webdav certs directory is required to exist, then it is created', async () => { diff --git a/test/services/validation.service.test.ts b/test/services/validation.service.test.ts index 269e4609..3be79192 100644 --- a/test/services/validation.service.test.ts +++ b/test/services/validation.service.test.ts @@ -73,4 +73,142 @@ describe('Validation Service', () => { expect(ValidationService.instance.validateStringIsNotEmpty('\t')).to.be.equal(false); expect(ValidationService.instance.validateStringIsNotEmpty('\t\n')).to.be.equal(false); }); + describe('parseJwtExpiration', () => { + it('When token is undefined, then returns null', () => { + expect(ValidationService.instance.validateJwtAndCheckExpiration(undefined)).to.be.equal(null); + }); + + it('When token is not a string, then returns null', () => { + expect(ValidationService.instance.validateJwtAndCheckExpiration('')).to.be.equal(null); + }); + + it('When token does not have 3 parts, then returns null', () => { + expect(ValidationService.instance.validateJwtAndCheckExpiration('invalid')).to.be.equal(null); + expect(ValidationService.instance.validateJwtAndCheckExpiration('invalid.token')).to.be.equal(null); + }); + + it('When token payload is not valid base64, then returns null', () => { + const invalidToken = 'header.!!!invalid_base64!!!.signature'; + expect(ValidationService.instance.validateJwtAndCheckExpiration(invalidToken)).to.be.equal(null); + }); + + it('When token payload does not contain exp claim, then returns null', () => { + const payload = btoa(JSON.stringify({ sub: 'user123' })); + const token = `header.${payload}.signature`; + expect(ValidationService.instance.validateJwtAndCheckExpiration(token)).to.be.equal(null); + }); + + it('When token payload exp is not a number, then returns null', () => { + const payload = btoa(JSON.stringify({ exp: 'not-a-number' })); + const token = `header.${payload}.signature`; + expect(ValidationService.instance.validateJwtAndCheckExpiration(token)).to.be.equal(null); + }); + + it('When token has valid structure with exp claim, then returns expiration timestamp', () => { + 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`; + expect(ValidationService.instance.validateJwtAndCheckExpiration(token)).to.be.equal(expiration); + }); + }); + + describe('checkTokenExpiration', () => { + it('When token expired more than 2 days ago, then expired is true and refreshRequired is false', () => { + const threeDaysAgo = Math.floor(Date.now() / 1000) - 3 * 24 * 60 * 60; + const result = ValidationService.instance.checkTokenExpiration(threeDaysAgo); + expect(result.expired).to.be.equal(true); + expect(result.refreshRequired).to.be.equal(false); + }); + + it('When token expired 1 second ago, then expired is true and refreshRequired is false', () => { + const oneSecondAgo = Math.floor(Date.now() / 1000) - 1; + const result = ValidationService.instance.checkTokenExpiration(oneSecondAgo); + expect(result.expired).to.be.equal(true); + expect(result.refreshRequired).to.be.equal(false); + }); + + it('When token expires in exactly 0 seconds (now), then expired is true', () => { + const now = Math.floor(Date.now() / 1000); + const result = ValidationService.instance.checkTokenExpiration(now); + expect(result.expired).to.be.equal(true); + expect(result.refreshRequired).to.be.equal(false); + }); + + it('When token expires in 1 day, then expired is false and refreshRequired is true', () => { + const oneDayFromNow = Math.floor(Date.now() / 1000) + 24 * 60 * 60; + const result = ValidationService.instance.checkTokenExpiration(oneDayFromNow); + expect(result.expired).to.be.equal(false); + expect(result.refreshRequired).to.be.equal(true); + }); + + it('When token expires in exactly 2 days, then expired is false and refreshRequired is true', () => { + const twoDaysFromNow = Math.floor(Date.now() / 1000) + 2 * 24 * 60 * 60; + const result = ValidationService.instance.checkTokenExpiration(twoDaysFromNow); + expect(result.expired).to.be.equal(false); + expect(result.refreshRequired).to.be.equal(true); + }); + + it('When token expires in 2 days + 1 second, then expired is false and refreshRequired is false', () => { + const twoDaysPlusOneSecond = Math.floor(Date.now() / 1000) + 2 * 24 * 60 * 60 + 1; + const result = ValidationService.instance.checkTokenExpiration(twoDaysPlusOneSecond); + expect(result.expired).to.be.equal(false); + expect(result.refreshRequired).to.be.equal(false); + }); + + it('When token expires in 30 days, then expired is false and refreshRequired is false', () => { + const thirtyDaysFromNow = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60; + const result = ValidationService.instance.checkTokenExpiration(thirtyDaysFromNow); + expect(result.expired).to.be.equal(false); + expect(result.refreshRequired).to.be.equal(false); + }); + }); + + describe('validateTokenAndCheckExpiration', () => { + it('When token is undefined, then returns invalid with expired true', () => { + const result = ValidationService.instance.validateTokenAndCheckExpiration(undefined); + expect(result.isValid).to.be.equal(false); + expect(result.expiration.expired).to.be.equal(true); + expect(result.expiration.refreshRequired).to.be.equal(false); + }); + + it('When token is malformed, then returns invalid with expired true', () => { + const result = ValidationService.instance.validateTokenAndCheckExpiration('invalid.token'); + expect(result.isValid).to.be.equal(false); + expect(result.expiration.expired).to.be.equal(true); + expect(result.expiration.refreshRequired).to.be.equal(false); + }); + + it('When token is valid but expired, then returns valid with expired true', () => { + const expiration = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago + const payload = btoa(JSON.stringify({ exp: expiration })); + const token = `header.${payload}.signature`; + + const result = ValidationService.instance.validateTokenAndCheckExpiration(token); + expect(result.isValid).to.be.equal(true); + expect(result.expiration.expired).to.be.equal(true); + expect(result.expiration.refreshRequired).to.be.equal(false); + }); + + it('When token is valid and expires in 1 day, then returns valid with refreshRequired true', () => { + const expiration = Math.floor(Date.now() / 1000) + 24 * 60 * 60; // 1 day from now + const payload = btoa(JSON.stringify({ exp: expiration })); + const token = `header.${payload}.signature`; + + const result = ValidationService.instance.validateTokenAndCheckExpiration(token); + expect(result.isValid).to.be.equal(true); + expect(result.expiration.expired).to.be.equal(false); + expect(result.expiration.refreshRequired).to.be.equal(true); + }); + + it('When token is valid and expires in 30 days, then returns valid with both false', () => { + 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 result = ValidationService.instance.validateTokenAndCheckExpiration(token); + expect(result.isValid).to.be.equal(true); + expect(result.expiration.expired).to.be.equal(false); + expect(result.expiration.refreshRequired).to.be.equal(false); + }); + }); }); diff --git a/test/utils/errors.utils.test.ts b/test/utils/errors.utils.test.ts index 6430bcfe..6a12ed4c 100644 --- a/test/utils/errors.utils.test.ts +++ b/test/utils/errors.utils.test.ts @@ -1,13 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ErrorUtils, isAlreadyExistsError } from '../../src/utils/errors.utils'; +import { ErrorUtils, isAlreadyExistsError, isOldTokenError, isFileNotFoundError } from '../../src/utils/errors.utils'; import { logger } from '../../src/utils/logger.utils'; -vi.mock('../../src/utils/logger.utils', () => ({ - logger: { - error: vi.fn(), - }, -})); - describe('Errors Utils', () => { beforeEach(() => { vi.clearAllMocks(); @@ -56,4 +50,73 @@ describe('Errors Utils', () => { expect(isAlreadyExistsError(undefined)).toBe(false); }); }); + + describe('isOldTokenError', () => { + it('should return true when error message contains "old token version detected"', () => { + const error = new Error('Something went wrong: old token version detected, please login again'); + + expect(isOldTokenError(error)).toBe(true); + }); + + it('should return true when error message contains "old token version detected" in mixed case', () => { + const error = new Error('Old Token Version Detected'); + + expect(isOldTokenError(error)).toBe(true); + }); + + it('it should return false when error message does not contain "old token version detected"', () => { + const error = new Error('Invalid token'); + + expect(isOldTokenError(error)).toBe(false); + }); + + it('should return false when error is not an Error object', () => { + expect(isOldTokenError('old token version detected')).toBe(false); + expect(isOldTokenError({ message: 'old token version detected' })).toBe(false); + expect(isOldTokenError(null)).toBe(false); + expect(isOldTokenError(undefined)).toBe(false); + expect(isOldTokenError(123)).toBe(false); + }); + }); + + describe('isFileNotFoundError', () => { + it('should return true when error has code ENOENT', () => { + const error = new Error('File not found'); + Object.assign(error, { code: 'ENOENT' }); + + expect(isFileNotFoundError(error)).toBe(true); + }); + + it('should return true when error is a real ENOENT error from fs operations', () => { + const error = Object.assign(new Error('ENOENT: no such file or directory'), { + code: 'ENOENT', + errno: -2, + syscall: 'open', + path: '/nonexistent/file.txt', + }); + + expect(isFileNotFoundError(error)).toBe(true); + }); + + it('should return false when error has a different error code', () => { + const error = new Error('Permission denied'); + Object.assign(error, { code: 'EACCES' }); + + expect(isFileNotFoundError(error)).toBe(false); + }); + + it('should return false when error has no code property', () => { + const error = new Error('Some error'); + + expect(isFileNotFoundError(error)).toBe(false); + }); + + it('should return false when error is not an Error object', () => { + expect(isFileNotFoundError({ code: 'ENOENT' })).toBe(false); + expect(isFileNotFoundError('ENOENT')).toBe(false); + expect(isFileNotFoundError(null)).toBe(false); + expect(isFileNotFoundError(undefined)).toBe(false); + expect(isFileNotFoundError(123)).toBe(false); + }); + }); }); diff --git a/test/vitest.setup.ts b/test/vitest.setup.ts new file mode 100644 index 00000000..26784920 --- /dev/null +++ b/test/vitest.setup.ts @@ -0,0 +1,16 @@ +import { vi } from 'vitest'; + +vi.mock('../src/utils/logger.utils', () => ({ + logger: { + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }, + webdavLogger: { + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }, +})); diff --git a/test/webdav/middlewares/request-logger.middleware.test.ts b/test/webdav/middlewares/request-logger.middleware.test.ts index f7535fc6..a3a58063 100644 --- a/test/webdav/middlewares/request-logger.middleware.test.ts +++ b/test/webdav/middlewares/request-logger.middleware.test.ts @@ -5,7 +5,7 @@ import { createWebDavRequestFixture, createWebDavResponseFixture } from '../../f describe('Request logger middleware', () => { beforeEach(() => { - vi.restoreAllMocks(); + vi.clearAllMocks(); }); it('When a request is received, should log only the specified methods', () => { diff --git a/vitest.config.mjs b/vitest.config.mjs index 4d4277a4..dc973f24 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -16,6 +16,6 @@ export default defineConfig({ ...coverageConfigDefaults.exclude ], }, - setupFiles: ['dotenv/config'] + setupFiles: ['dotenv/config', './test/vitest.setup.ts'] } });