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..5679ae0e
--- /dev/null
+++ b/src/__tests__/auth.device.spec.ts
@@ -0,0 +1,231 @@
+///
+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').mockResolvedValue({
+ status: 404,
+ data: {},
+ } as any);
+
+ await expect((Auth as any).getAuthFlowConfig('api.fyndx1.de')).resolves.toEqual({
+ auth_mode_type: 'legacy',
+ });
+ expect(process.exitCode).toBe(0);
+ });
+
+ 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 regional Skywarp URLs for device login when region is provided', async () => {
+ const getSpy = jest.spyOn(ApiClient, 'get').mockResolvedValue({
+ data: {
+ client_id: 'fdk-cli',
+ auth_mode_type: 'device_code',
+ },
+ } as any);
+
+ const postSpy = jest.spyOn(ApiClient, 'post');
+ postSpy
+ .mockResolvedValueOnce({
+ data: {
+ device_code: 'device-code-region',
+ verification_uri_complete:
+ 'https://partners.fyndx1.de/partners/organizations/?device_id=device-code-region®ion=asia-south1%2Fdevelopment',
+ interval: 0,
+ expires_in: 10,
+ },
+ } as any)
+ .mockResolvedValueOnce({
+ data: {
+ auth_token: {
+ access_token: 'region-token',
+ 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', region: 'asia-south1/development' });
+
+ expect(getSpy).toHaveBeenCalledWith(
+ 'https://api.fyndx1.de/region/asia-south1/development/service/panel/authentication/v1.0/oauth/client-config',
+ expect.objectContaining({
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ }),
+ expect.objectContaining({
+ validateStatus: expect.any(Function),
+ }),
+ );
+ expect(postSpy).toHaveBeenNthCalledWith(
+ 1,
+ 'https://api.fyndx1.de/region/asia-south1/development/service/panel/authentication/v1.0/oauth/device_authorization',
+ expect.objectContaining({
+ data: expect.objectContaining({
+ requested_region: 'asia-south1/development',
+ }),
+ }),
+ );
+ expect(postSpy).toHaveBeenNthCalledWith(
+ 2,
+ 'https://api.fyndx1.de/region/asia-south1/development/service/panel/authentication/v1.0/oauth/token',
+ expect.objectContaining({
+ data: expect.objectContaining({
+ device_code: 'device-code-region',
+ }),
+ }),
+ );
+ expect(openMock).toHaveBeenCalledWith(
+ 'https://partners.fyndx1.de/partners/organizations/?device_id=device-code-region®ion=asia-south1%2Fdevelopment',
+ );
+ expect(configStore.get(CONFIG_KEYS.AUTH_TOKEN).access_token).toBe('region-token');
+ });
+
+ it('uses device flow and opens verification URL unchanged', async () => {
+ const getSpy = jest.spyOn(ApiClient, 'get').mockResolvedValue({
+ data: {
+ client_id: 'fdk-cli',
+ auth_mode_type: 'device_code',
+ },
+ } as any);
+
+ const postSpy = jest.spyOn(ApiClient, 'post');
+ postSpy
+ .mockResolvedValueOnce({
+ data: {
+ device_code: 'device-code-1',
+ verification_uri_complete: 'https://partners.fyndx1.de/partners/organizations/?device_id=device-code-basic',
+ 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).toBe('https://partners.fyndx1.de/partners/organizations/?device_id=device-code-basic');
+ 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_type: 'device_code',
+ },
+ } as any);
+
+ const postSpy = jest.spyOn(ApiClient, 'post');
+ postSpy
+ .mockResolvedValueOnce({
+ data: {
+ device_code: 'device-code-2',
+ verification_uri_complete:
+ 'https://partners.fyndx1.de/partners/organizations/?device_id=device-code-expired',
+ 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..4f8f59c1
--- /dev/null
+++ b/src/__tests__/url.spec.ts
@@ -0,0 +1,58 @@
+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';
+ const regionalAuthBase =
+ 'https://api.test.example.com/region/asia-south1/development/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`);
+ });
+
+ it('OAuth URLs support regional Skywarp paths', () => {
+ const options = { region: 'asia-south1/development' };
+
+ expect(URLS.OAUTH_CLIENT_CONFIG(options)).toBe(
+ `${regionalAuthBase}/oauth/client-config`,
+ );
+ expect(URLS.OAUTH_DEVICE_AUTHORIZATION(options)).toBe(
+ `${regionalAuthBase}/oauth/device_authorization`,
+ );
+ expect(URLS.OAUTH_DEVICE_TOKEN(options)).toBe(
+ `${regionalAuthBase}/oauth/token`,
+ );
+ });
+});
diff --git a/src/helper/constants.ts b/src/helper/constants.ts
index 46860e79..631d7c2c 100644
--- a/src/helper/constants.ts
+++ b/src/helper/constants.ts
@@ -14,6 +14,9 @@ export const ENVIRONMENT_COMMANDS = ['env'];
export const AUTHENTICATION_COMMANDS = ['auth', 'login', 'logout'];
export const EXTENSION_COMMANDS = ['init', 'get', 'set', 'pull-env'];
export const MAX_RETRY = 5;
+export const FDK_CLI_CLIENT_ID = 'fdk-cli';
+export const DEVICE_CODE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
+export const DEVICE_AUTH_SCOPES = ['organization/*'];
export const THEME_TYPE = {
vue2: 'vue2',
react: 'react',
@@ -111,4 +114,4 @@ export const PROJECT_REPOS = {
[TEMPLATES['node-vue'].name]: TEMPLATES['node-vue'].repo,
[TEMPLATES['node-react'].name]: TEMPLATES['node-react'].repo,
[TEMPLATES['payment-node-react'].name]: TEMPLATES['payment-node-react'].repo
-};
\ No newline at end of file
+};
diff --git a/src/lib/Auth.ts b/src/lib/Auth.ts
index c7ddbba6..cadf0ad6 100644
--- a/src/lib/Auth.ts
+++ b/src/lib/Auth.ts
@@ -18,6 +18,9 @@ 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';
+import { URLS } from './api/services/url';
+import { DEVICE_AUTH_SCOPES, DEVICE_CODE_GRANT_TYPE, FDK_CLI_CLIENT_ID } from '../helper/constants';
async function checkTokenExpired(auth_token) {
const { expiry_time } = auth_token;
@@ -29,6 +32,12 @@ async function checkTokenExpired(auth_token) {
}
}
+const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+function getRegionFromOptions(options: any) {
+ return options?.region?.trim();
+}
+
export const getApp = async () => {
const app = express();
let isLoading = false;
@@ -145,6 +154,121 @@ export default class Auth {
static wantToChangeOrganization = false;
static newDomainToUpdate = null;
constructor() { }
+
+ private static async getAuthFlowConfig(env: string, region?: string) {
+ try {
+ const url = URLS.OAUTH_CLIENT_CONFIG({ env, region });
+ const response = await ApiClient.get(url, {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ }, {
+ validateStatus: (status) =>
+ (status >= 200 && status < 300) || status === 404,
+ });
+ if (response.status === 404) {
+ return { auth_mode_type: 'legacy' };
+ }
+ return response.data || {};
+ } catch (error) {
+ if (error?.response?.status === 404) {
+ return { auth_mode_type: 'legacy' };
+ }
+ throw error;
+ }
+ }
+
+ private static shouldUseDeviceFlow(config: { auth_mode_type?: string }) {
+ return (config?.auth_mode_type || '').toLowerCase() === 'device_code';
+ }
+
+ private static async runDeviceLogin(env: string, options: any) {
+ const region = getRegionFromOptions(options);
+ const urlOptions = { env, region };
+ const response = await ApiClient.post(URLS.OAUTH_DEVICE_AUTHORIZATION(urlOptions), {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ data: {
+ client_id: FDK_CLI_CLIENT_ID,
+ scope: DEVICE_AUTH_SCOPES,
+ requested_host: env,
+ requested_region: region,
+ },
+ });
+ const {
+ device_code,
+ verification_uri_complete,
+ interval = 5,
+ expires_in = 600,
+ } = response.data;
+
+ const verificationLink = verification_uri_complete;
+
+ try {
+ await open(verificationLink);
+ Logger.info(`Opened link to start the auth process: ${OutputFormatter.link(verificationLink)}`);
+ } catch (err) {
+ Logger.info(`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(URLS.OAUTH_DEVICE_TOKEN(urlOptions), {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ data: {
+ grant_type: DEVICE_CODE_GRANT_TYPE,
+ client_id: FDK_CLI_CLIENT_ID,
+ 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 === '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,16 +310,23 @@ export default class Auth {
return;
} else {
Auth.wantToChangeOrganization = true;
- await startServer(port);
}
});
- } else
- await startServer(port);
+ if (!Auth.wantToChangeOrganization) {
+ return;
+ }
+ }
try {
+ const region = getRegionFromOptions(options);
+ const authFlowConfig = await Auth.getAuthFlowConfig(env, region);
+ 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}`;
- const region = options.region?.trim();
const callbackUrl = `${getLocalBaseUrl()}:${port}`;
const queryParams = new URLSearchParams({ 'fdk-cli': 'true', callback: callbackUrl });
if (region) queryParams.set('region', region);
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..17e87fb3 100644
--- a/src/lib/api/services/url.ts
+++ b/src/lib/api/services/url.ts
@@ -5,6 +5,11 @@ import Logger from '../../Logger';
const apiVersion = configStore.get(CONFIG_KEYS.API_VERSION) || '1.0';
const getOrganizationId = () => configStore.get(CONFIG_KEYS.ORGANIZATION);
+type OAuthURLBuildOptions = {
+ env?: string;
+ region?: string;
+};
+
export const getBaseURL = () => {
const currentEnv = configStore.get(CONFIG_KEYS.CURRENT_ENV_VALUE);
return `https://${currentEnv}`;
@@ -31,6 +36,11 @@ const ASSET_URL = () => getBaseURL() + '/service/partner/assets/v2.0';
const LOCALES_URL = () => getBaseURL() + '/service/partner/content/v' + apiVersion;
const PAYMENT_URL = () => getBaseURL() + '/service/partner/payment/v' + apiVersion;
+const OAUTH_URL = (options?: OAuthURLBuildOptions) => {
+ const baseURL = options?.env ? `https://${options.env}` : getBaseURL();
+ const regionalBaseURL = options?.region ? urlJoin(baseURL, `/region/${options.region}`) : baseURL;
+ return regionalBaseURL + '/service/panel/authentication/v' + apiVersion;
+};
export const URLS = {
// AUTHENTICATION
@@ -43,6 +53,15 @@ export const URLS = {
VERIFY_OTP: () => {
return urlJoin(AUTH_URL(), '/auth/login/mobile/otp/verify');
},
+ OAUTH_CLIENT_CONFIG: (options?: OAuthURLBuildOptions) => {
+ return urlJoin(OAUTH_URL(options), '/oauth/client-config');
+ },
+ OAUTH_DEVICE_AUTHORIZATION: (options?: OAuthURLBuildOptions) => {
+ return urlJoin(OAUTH_URL(options), '/oauth/device_authorization');
+ },
+ OAUTH_DEVICE_TOKEN: (options?: OAuthURLBuildOptions) => {
+ return urlJoin(OAUTH_URL(options), '/oauth/token');
+ },
//CONFIGURATION
GET_APPLICATION_DETAILS: (company_id: number, application_id: string) => {