diff --git a/src/__tests__/api.interceptor.spec.ts b/src/__tests__/api.interceptor.spec.ts
new file mode 100644
index 00000000..7fbb3ad7
--- /dev/null
+++ b/src/__tests__/api.interceptor.spec.ts
@@ -0,0 +1,21 @@
+///
+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();
+ });
+});
diff --git a/src/__tests__/auth.device.spec.ts b/src/__tests__/auth.device.spec.ts
new file mode 100644
index 00000000..7dc1c722
--- /dev/null
+++ b/src/__tests__/auth.device.spec.ts
@@ -0,0 +1,165 @@
+///
+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');
+ }
+ });
+});
diff --git a/src/__tests__/url.spec.ts b/src/__tests__/url.spec.ts
new file mode 100644
index 00000000..16187e9a
--- /dev/null
+++ b/src/__tests__/url.spec.ts
@@ -0,0 +1,42 @@
+jest.mock('../lib/Config', () => {
+ const actual = jest.requireActual(
+ '../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`);
+ });
+});
diff --git a/src/lib/Auth.ts b/src/lib/Auth.ts
index c7ddbba6..2139b199 100644
--- a/src/lib/Auth.ts
+++ b/src/lib/Auth.ts
@@ -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;
@@ -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;
@@ -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;
@@ -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}`;
diff --git a/src/lib/api/helper/interceptors.ts b/src/lib/api/helper/interceptors.ts
index d248159c..c3e86ad0 100644
--- a/src/lib/api/helper/interceptors.ts
+++ b/src/lib/api/helper/interceptors.ts
@@ -10,6 +10,8 @@ import { transformRequestOptions } from '../../../helper/utils';
import fs from 'fs-extra';
import https from 'https'
+const packageJSON = require('../../../../package.json');
+
function getTransformer(config) {
const { transformRequest } = config;
@@ -40,6 +42,8 @@ function interceptorFn(options) {
}
const { host, pathname, search } = new URL(url);
if (pathname.includes('/service') || pathname.startsWith('/ext')) {
+ config.headers = config.headers || {};
+ config.headers['x-fp-cli'] = config.headers['x-fp-cli'] || `${packageJSON.version}`;
const { data, headers, method, params } = config;
// set cookie
const cookie = ConfigStore.get(CONFIG_KEYS.COOKIE);
diff --git a/src/lib/api/services/url.ts b/src/lib/api/services/url.ts
index 61888fd4..e25cc8e6 100644
--- a/src/lib/api/services/url.ts
+++ b/src/lib/api/services/url.ts
@@ -43,6 +43,15 @@ export const URLS = {
VERIFY_OTP: () => {
return urlJoin(AUTH_URL(), '/auth/login/mobile/otp/verify');
},
+ OAUTH_CLIENT_CONFIG: () => {
+ return urlJoin(AUTH_URL(), '/oauth/client-config');
+ },
+ OAUTH_DEVICE_AUTHORIZATION: () => {
+ return urlJoin(AUTH_URL(), '/oauth/device_authorization');
+ },
+ OAUTH_DEVICE_TOKEN: () => {
+ return urlJoin(AUTH_URL(), '/oauth/token');
+ },
//CONFIGURATION
GET_APPLICATION_DETAILS: (company_id: number, application_id: string) => {