Skip to content
21 changes: 21 additions & 0 deletions src/__tests__/api.interceptor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/// <reference types="jest" />
import { addSignatureFn } from '../lib/api/helper/interceptors';

const packageJSON = require('../../package.json');

describe('API request interceptor', () => {
it('adds fdk cli version header before service requests are signed', async () => {
const interceptor = addSignatureFn({});
const config: any = {
url: 'https://api.fyndx1.de/service/panel/authentication/v1.0/oauth/token',
method: 'get',
headers: {},
};

const signedConfig = await interceptor(config);

expect(signedConfig.headers['x-fp-cli']).toBe(`${packageJSON.version}`);
expect(signedConfig.headers['x-fp-date']).toBeDefined();
expect(signedConfig.headers['x-fp-signature']).toBeDefined();
});
});
165 changes: 165 additions & 0 deletions src/__tests__/auth.device.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/// <reference types="jest" />
import rimraf from 'rimraf';
import Auth from '../lib/Auth';
import ApiClient from '../lib/api/ApiClient';
import Env from '../lib/Env';
import configStore, { CONFIG_KEYS } from '../lib/Config';
import OrganizationService from '../lib/api/services/organization.service';
import CommandError from '../lib/CommandError';
import Logger from '../lib/Logger';

const openMock = jest.fn();

jest.mock('open', () => ({
__esModule: true,
default: (...args) => openMock(...args),
}));

jest.mock('../helper/formatter', () => ({
OutputFormatter: {
link: (value: string) => value,
command: (value: string) => value,
},
successBox: ({ text }: { text: string }) => text,
}));

jest.mock('configstore', () => {
const Store = jest.requireActual('configstore');
return class MockConfigstore {
store = new Store('test-cli', undefined, {
configPath: './auth-device-test-cli.json',
});
get(key: string) {
return this.store.get(key);
}
set(key: string, value) {
this.store.set(key, value);
}
delete(key) {
this.store.delete(key);
}
clear() {
this.store.clear();
}
};
});

describe('Auth device flow', () => {
beforeEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
configStore.clear();
process.exitCode = 0;

jest.spyOn(Env, 'verifyAndSanitizeEnvValue').mockResolvedValue('api.fyndx1.de');
jest.spyOn(Env, 'getEnvValue').mockReturnValue('api.fyndx1.de');
jest.spyOn(Logger, 'info').mockImplementation(() => {});
jest.spyOn(OrganizationService, 'getOrganizationDetails').mockResolvedValue({
data: { _id: 'org-1', name: 'Test Org' },
} as any);
openMock.mockResolvedValue(undefined);
});

afterEach(() => {
process.exitCode = 0;
});

afterAll(() => {
rimraf.sync('./auth-device-test-cli.json');
});

it('falls back to legacy when client-config endpoint is unavailable', async () => {
jest.spyOn(ApiClient, 'get').mockRejectedValue({
response: { status: 404 },
});

await expect((Auth as any).getAuthFlowConfig('api.fyndx1.de')).resolves.toEqual({
auth_mode: 'legacy',
});
});

it('does not fall back to legacy when client-config returns an unexpected error', async () => {
const error = {
message: 'Internal server error',
response: { status: 500 },
};
jest.spyOn(ApiClient, 'get').mockRejectedValue(error);

await expect((Auth as any).getAuthFlowConfig('api.fyndx1.de')).rejects.toBe(error);
});

it('uses device flow and appends missing URL params', async () => {
const getSpy = jest.spyOn(ApiClient, 'get').mockResolvedValue({
data: {
client_id: 'fdk-cli',
auth_mode: 'device_code',
},
} as any);

const postSpy = jest.spyOn(ApiClient, 'post');
postSpy
.mockResolvedValueOnce({
data: {
device_code: 'device-code-1',
user_code: 'ABCD-EFGH',
verification_uri_complete: 'https://partners.fyndx1.de/activate-with-code',
interval: 0,
expires_in: 10,
},
} as any)
.mockResolvedValueOnce({
data: {
auth_token: {
access_token: 'token-1',
expires_in: 3600,
current_user: { first_name: 'A', last_name: 'B', emails: [] },
},
organization: { _id: 'org-1', name: 'Test Org' },
},
} as any);

await Auth.login({ host: 'api.fyndx1.de' });

expect(getSpy).toHaveBeenCalled();
expect(postSpy).toHaveBeenCalledTimes(2);
expect(openMock).toHaveBeenCalledTimes(1);
const openedUrl = openMock.mock.calls[0][0] as string;
expect(openedUrl).toContain('user_code=ABCD-EFGH');
expect(openedUrl).toContain('device_flow=true');
expect(configStore.get(CONFIG_KEYS.AUTH_TOKEN).access_token).toBe('token-1');
});

it('maps expired_token polling response to CommandError', async () => {
jest.spyOn(ApiClient, 'get').mockResolvedValue({
data: {
client_id: 'fdk-cli',
auth_mode: 'device_code',
},
} as any);

const postSpy = jest.spyOn(ApiClient, 'post');
postSpy
.mockResolvedValueOnce({
data: {
device_code: 'device-code-2',
user_code: 'WXYZ-2345',
verification_uri_complete:
'https://partners.fyndx1.de/activate-with-code?user_code=WXYZ-2345&device_flow=true',
interval: 0,
expires_in: 10,
},
} as any)
.mockRejectedValueOnce({
response: { data: { error: 'expired_token' } },
});

try {
await Auth.login({ host: 'api.fyndx1.de' });
fail('Expected Auth.login to throw for expired_token');
} catch (error) {
expect(error).toBeInstanceOf(CommandError);
expect(error.message).toBe('Device code expired. Please run `fdk login` again.');
expect(error.code).toBe('400');
}
});
});
42 changes: 42 additions & 0 deletions src/__tests__/url.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
jest.mock('../lib/Config', () => {
const actual = jest.requireActual<typeof import('../lib/Config')>(
'../lib/Config',
);
const { CONFIG_KEYS } = actual;
return {
__esModule: true,
default: {
get: (key: string) => {
if (key === CONFIG_KEYS.API_VERSION) return '1.0';
if (key === CONFIG_KEYS.CURRENT_ENV_VALUE) {
return 'api.test.example.com';
}
return undefined;
},
},
CONFIG_KEYS: actual.CONFIG_KEYS,
};
});

import { URLS } from '../lib/api/services/url';

describe('URLS OAuth (device flow)', () => {
const authBase =
'https://api.test.example.com/service/panel/authentication/v1.0';

it('OAUTH_CLIENT_CONFIG builds panel client-config URL', () => {
expect(URLS.OAUTH_CLIENT_CONFIG()).toBe(
`${authBase}/oauth/client-config`,
);
});

it('OAUTH_DEVICE_AUTHORIZATION builds device authorization URL', () => {
expect(URLS.OAUTH_DEVICE_AUTHORIZATION()).toBe(
`${authBase}/oauth/device_authorization`,
);
});

it('OAUTH_DEVICE_TOKEN builds OAuth token URL', () => {
expect(URLS.OAUTH_DEVICE_TOKEN()).toBe(`${authBase}/oauth/token`);
});
});
137 changes: 134 additions & 3 deletions src/lib/Auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { OutputFormatter, successBox } from '../helper/formatter';
import OrganizationService from './api/services/organization.service';
import { getOrganizationDisplayName } from '../helper/utils';
import ExtensionContext from './ExtensionContext';
import ApiClient from './api/ApiClient';

async function checkTokenExpired(auth_token) {
const { expiry_time } = auth_token;
Expand All @@ -29,6 +30,8 @@ async function checkTokenExpired(auth_token) {
}
}

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export const getApp = async () => {
const app = express();
let isLoading = false;
Expand Down Expand Up @@ -145,6 +148,127 @@ export default class Auth {
static wantToChangeOrganization = false;
static newDomainToUpdate = null;
constructor() { }

private static async getAuthFlowConfig(env: string) {
try {
const url = `https://${env}/service/panel/authentication/v1.0/oauth/client-config`;
const response = await ApiClient.get(url, {
params: { client_id: 'fdk-cli' },
headers: {
'Content-Type': 'application/json',
},
});
return response.data || {};
} catch (error) {
if (error?.response?.status === 404) {
return { auth_mode: 'legacy' };
}
throw error;
}
}

private static shouldUseDeviceFlow(config: { auth_mode?: string }) {
return (config?.auth_mode || '').toLowerCase() === 'device_code';
}

private static async runDeviceLogin(env: string, options: any) {
const authBase = `https://${env}/service/panel/authentication/v1.0`;
const response = await ApiClient.post(`${authBase}/oauth/device_authorization`, {
headers: {
'Content-Type': 'application/json',
},
data: {
client_id: 'fdk-cli',
scope: ['organization/*'],
requested_host: env,
requested_region: options.region?.trim(),
},
});
const {
device_code,
user_code,
verification_uri_complete,
interval = 5,
expires_in = 600,
} = response.data;

const verificationUrl = new URL(verification_uri_complete);
if (!verificationUrl.searchParams.has('user_code')) {
verificationUrl.searchParams.set('user_code', user_code);
}
if (!verificationUrl.searchParams.has('device_flow')) {
verificationUrl.searchParams.set('device_flow', 'true');
}
const verificationLink = verificationUrl.toString();

Logger.info(`User verification code: ${chalk.cyan(user_code)}`);
try {
await open(verificationLink);
console.log(`Opened link to start the auth process: ${OutputFormatter.link(verificationLink)}`);
} catch (err) {
console.log(`Open this link to continue login: ${OutputFormatter.link(verificationLink)}`);
}

const maxAttempts = Math.ceil(expires_in / interval);
for (let attempt = 0; attempt < maxAttempts; attempt++) {
await sleep(interval * 1000);
try {
const tokenRes = await ApiClient.post(`${authBase}/oauth/token`, {
headers: {
'Content-Type': 'application/json',
},
data: {
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
client_id: 'fdk-cli',
device_code,
},
});
const authToken = tokenRes.data.auth_token;
const organization = tokenRes.data.organization;
if (Auth.wantToChangeOrganization) {
ConfigStore.delete(CONFIG_KEYS.AUTH_TOKEN);
clearExtensionContext();
}
const expiryTimestamp =
Math.floor(Date.now() / 1000) + authToken.expires_in;
authToken.expiry_time = expiryTimestamp;
if (Auth.newDomainToUpdate) {
if (Auth.newDomainToUpdate === 'api.fynd.com') {
Env.setEnv(Auth.newDomainToUpdate);
}
else {
await Env.setNewEnvs(Auth.newDomainToUpdate);
}
}
ConfigStore.set(CONFIG_KEYS.AUTH_TOKEN, authToken);
ConfigStore.set(CONFIG_KEYS.ORGANIZATION, organization);
const organization_detail =
await OrganizationService.getOrganizationDetails();
ConfigStore.set(
CONFIG_KEYS.ORGANIZATION_DETAIL,
organization_detail.data,
);
Logger.info(`Logged in successfully in organization ${getOrganizationDisplayName()}`);
return;
} catch (error) {
const oauthError = error?.response?.data?.error;
if (oauthError === 'authorization_pending') continue;
if (oauthError === 'slow_down') {
await sleep(2000);
continue;
}
if (oauthError === 'access_denied') {
throw new CommandError('Login denied in browser.', '403');
}
if (oauthError === 'expired_token') {
throw new CommandError('Device code expired. Please run `fdk login` again.', '400');
}
throw error;
}
}
throw new CommandError('Login timed out. Please run `fdk login` again.', '408');
}

public static async login(options) {

let env: string;
Expand Down Expand Up @@ -186,12 +310,19 @@ export default class Auth {
return;
} else {
Auth.wantToChangeOrganization = true;
await startServer(port);
}
});
} else
await startServer(port);
if (!Auth.wantToChangeOrganization) {
return;
}
}
try {
const authFlowConfig = await Auth.getAuthFlowConfig(env);
if (Auth.shouldUseDeviceFlow(authFlowConfig)) {
await Auth.runDeviceLogin(env, options);
return;
}
await startServer(port);
let domain = null;
let partnerDomain = env.replace('api', 'partners');
domain = `https://${partnerDomain}`;
Expand Down
Loading
Loading