Skip to content
Open
24 changes: 18 additions & 6 deletions src/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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<LoginCredentials> => {
let loginCreds = await ConfigService.instance.readUser();
const loginCreds = await ConfigService.instance.readUser();
if (!loginCreds?.token || !loginCreds?.user?.mnemonic) {
throw new MissingCredentialsError();
}
Expand All @@ -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<LoginCredentials> => {
SdkManager.init({ token: oldToken });
Expand Down
15 changes: 10 additions & 5 deletions src/services/config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -49,12 +50,16 @@ export class ConfigService {
* @async
**/
public clearUser = async (): Promise<void> => {
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
Expand Down
76 changes: 50 additions & 26 deletions src/services/validation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
): {
Expand All @@ -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 },
};
};
}
17 changes: 17 additions & 0 deletions src/utils/errors.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}) {
if (isError(error)) {
Expand Down Expand Up @@ -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);
}
}
61 changes: 61 additions & 0 deletions test/services/auth.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
29 changes: 21 additions & 8 deletions test/services/config.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading