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
19 changes: 13 additions & 6 deletions src/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,12 @@ 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
*/
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 +82,22 @@ 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) {
await ConfigService.instance.clearUser();
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
75 changes: 49 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,10 @@ 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 ? this.checkTokenExpiration(expiration) : { expired: true, refreshRequired: false },
};
};
}
5 changes: 5 additions & 0 deletions src/utils/errors.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export function isAlreadyExistsError(error: unknown): error is Error {
(typeof error === 'object' && error !== null && 'status' in error && error.status === 409)
);
}

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
24 changes: 24 additions & 0 deletions test/services/auth.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,4 +236,28 @@ describe('Auth service', () => {
expect(validateMnemonicStub).toHaveBeenCalledWith(UserCredentialsFixture.user.mnemonic);
expect(refreshTokensStub).toHaveBeenCalledOnce();
});

it('should clear and throw exception when exception is thrown while refreshing user token', 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();

await expect(() => sut.getAuthDetails()).rejects.toThrow(oldTokenError);
expect(refreshTokenStub).toHaveBeenCalledOnce();
expect(clearUserStub).toHaveBeenCalledOnce();
});
});
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
138 changes: 138 additions & 0 deletions test/services/validation.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Loading