diff --git a/README.md b/README.md index fb22ef4..f6d1696 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,31 @@ A CLI to download and upload serialized representations of full [ordercloud](htt | Mac | [seeding-macos](https://raw.githubusercontent.com/ordercloud-api/ordercloud-seed/main/exe/seeding-macos) | | Linux | [seeding-linux](https://raw.githubusercontent.com/ordercloud-api/ordercloud-seed/main/exe/seeding-linux) | +> [!NOTE] +> The Portal credentials are no longer supported by the Sitecore Cloud Portal. Instead, you can use Client Credentials for the OrderCloud Seed tool to access your marketplace. + +### Client Credentials + +Create a JSON in the below format: + +``` +[ + { + "Name": "Sandbox", + "OrderCloudBaseUrl": "https://sandbox.ordercloud.io", + "ApiClientId": "00000000-0000-0000-0000-000000000000", + "ApiClientSecret": "abcdefghijklmnopqrstuvwxyz" + }, + { + "Name": "Staging", + "OrderCloudBaseUrl": "https://sandbox.ordercloud.io", + "ApiClientId": "00000000-0000-0000-0000-000000000000", + "ApiClientSecret": "abcdefghijklmnopqrstuvwxyz" + } +] +``` + +The JSON file is used to request the access token using the client credentials. The API Client needs to have a default context user assigned that has the `FullAccess` role to ensure all API resources can be accessed. ## CLI Usage @@ -47,6 +72,16 @@ Validate that a local file would seed successfully. npx @ordercloud/seeding validate my-file.yml ``` +Download a marketplace using Client Credentials +``` +npx @ordercloud/seeding download new-file-to-create.yml --config ~/ordercloud-seed-config.json --target Sandbox +``` + +Seed a marketplace using Client Credentials +``` +npx @ordercloud/seeding seed seed-data-file.yml --config ~/ordercloud-seed-config.json --target Staging +``` + ## Javascript API Usage ```typescript diff --git a/exe/seeding-linux b/exe/seeding-linux index 041cf79..5722694 100644 Binary files a/exe/seeding-linux and b/exe/seeding-linux differ diff --git a/exe/seeding-macos b/exe/seeding-macos index 1a7f6d6..0918138 100644 Binary files a/exe/seeding-macos and b/exe/seeding-macos differ diff --git a/exe/seeding-win.exe b/exe/seeding-win.exe index f961a18..36a75e4 100644 Binary files a/exe/seeding-win.exe and b/exe/seeding-win.exe differ diff --git a/src/cli.ts b/src/cli.ts index d70374d..25b8e7f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -43,6 +43,16 @@ yargs.scriptName("@ordercloud/seeding") alias: 'r', describe: 'Region for the marketplace. See the API docs for more information' }) + yargs.option('target', { + type: 'string', + alias: 't', + describe: 'Target environment name matching the Name attribute in your config file' + }) + yargs.option('config', { + type: 'string', + alias: 'c', + describe: 'Path to the config file for client credentials authentication' + }) }, function (argv) { var dataUrl = argv.d as string; // Check for short-cut aliases @@ -68,6 +78,8 @@ yargs.scriptName("@ordercloud/seeding") marketplaceID: argv.i as string, marketplaceName: argv.n as string, regionId: argv.r as string, + target: argv.t as string, + configPath: argv.c as string, rawData: data }); return; @@ -83,6 +95,8 @@ yargs.scriptName("@ordercloud/seeding") marketplaceID: argv.i as string, marketplaceName: argv.n as string, regionId: argv.r as string, + target: argv.t as string, + configPath: argv.c as string, dataUrl: dataUrl as string }); }) @@ -102,6 +116,16 @@ yargs.scriptName("@ordercloud/seeding") alias: 'p', describe: 'Portal password' }) + yargs.option('target', { + type: 'string', + alias: 't', + describe: 'Target environment name matching the Name attribute in your config file' + }) + yargs.option('config', { + type: 'string', + alias: 'c', + describe: 'Path to the environments config file' + }) yargs.positional('fileName', { type: 'string', alias: 'f', @@ -113,7 +137,9 @@ yargs.scriptName("@ordercloud/seeding") var data = await download({ username: argv.u as string, password: argv.p as string, - marketplaceID: argv.i as string, + marketplaceID: argv.i as string, + target: argv.t as string, + configPath: argv.c as string, }); if (data) { var path = argv.f as string ?? 'ordercloud-seed.yml'; diff --git a/src/commands/download.ts b/src/commands/download.ts index acf6e52..88ab0da 100644 --- a/src/commands/download.ts +++ b/src/commands/download.ts @@ -11,34 +11,72 @@ import PortalAPI from '../services/portal'; import Bottleneck from 'bottleneck'; import { OCResourceEnum } from '../models/oc-resource-enum'; import { RefreshTimer } from '../services/refresh-timer'; +import { ConfigLoader } from '../services/config-loader'; +import jwtDecode from 'jwt-decode'; export interface DownloadArgs { - username?: string; - password?: string; - marketplaceID: string; + username?: string; + password?: string; + marketplaceID?: string; portalToken?: string; + target?: string; + configPath?: string; logger?: LogCallBackFunc } export async function download(args: DownloadArgs): Promise { - var { - username, - password, - marketplaceID, + var { + username, + password, + marketplaceID, portalToken, + target, + configPath, logger = defaultLogger } = args; - if (!marketplaceID) { - return logger(`Missing required argument: marketplaceID`, MessageType.Error); - } - // Authenticate var portal = new PortalAPI(); var portalRefreshToken: string; var org_token: string; var userLoginAuthUsed = _.isNil(portalToken); - if (userLoginAuthUsed) { + var clientCredentialsUsed = !_.isNil(target); + + if (clientCredentialsUsed) { + // Client Credentials Flow + try { + const config = ConfigLoader.load(target, configPath); + logger(`Using client credentials from target "${target}"`, MessageType.Success); + + // Authenticate directly with OrderCloud API using client credentials + const tokenResponse = await portal.loginWithClientCredentials( + config.ApiClientId, + config.ApiClientSecret, + config.OrderCloudBaseUrl + ); + org_token = tokenResponse.access_token; + + // Decode the JWT token to extract the marketplace ID + const decodedToken: any = jwtDecode(org_token); + const tokenMarketplaceID = decodedToken.cid; + + // Use marketplace ID from token if not provided + if (_.isNil(marketplaceID)) { + marketplaceID = tokenMarketplaceID; + logger(`Using marketplace ID from token: ${marketplaceID}`, MessageType.Success); + } else if (marketplaceID !== tokenMarketplaceID) { + return logger(`Provided marketplace ID "${marketplaceID}" does not match the client credentials marketplace ID "${tokenMarketplaceID}"`, MessageType.Error); + } + + Configuration.Set({ baseApiUrl: config.OrderCloudBaseUrl }); + Tokens.SetAccessToken(org_token); + + logger(`Authenticated with client credentials to ${config.OrderCloudBaseUrl}`, MessageType.Success); + } catch (error) { + return logger(`Client credentials authentication failed: ${error.message}`, MessageType.Error); + } + } else if (userLoginAuthUsed) { + // Portal Login Flow if (_.isNil(username) || _.isNil(password)) { return logger(`Missing required arguments: username and password`, MessageType.Error) } @@ -51,21 +89,29 @@ export async function download(args: DownloadArgs): Promise { - var { - username, - password, - marketplaceID = Random.generateOrgID(), + var { + username, + password, + marketplaceID = Random.generateOrgID(), marketplaceName, portalToken, + target, + configPath, rawData, dataUrl, regionId = "usw", @@ -59,12 +65,41 @@ export async function seed(args: SeedArgs): Promise { var validateResponse = await validate({ rawData, dataUrl}); if (validateResponse?.errors?.length !== 0) return; - // Authenticate To Portal + // Authenticate To Portal or using Client Credentials var portal = new PortalAPI(); var portalRefreshToken: string; var userLoginAuthUsed = _.isNil(portalToken); + var clientCredentialsUsed = !_.isNil(target); + var org_token: string; - if (userLoginAuthUsed) { + if (clientCredentialsUsed) { + // Client Credentials Flow + try { + const config = ConfigLoader.load(target, configPath); + logger(`Using client credentials from target "${target}"`, MessageType.Success); + + // Authenticate directly with OrderCloud API using client credentials + const tokenResponse = await portal.loginWithClientCredentials( + config.ApiClientId, + config.ApiClientSecret, + config.OrderCloudBaseUrl + ); + org_token = tokenResponse.access_token; + + // Decode the JWT token to extract the marketplace ID + const decodedToken: any = jwtDecode(org_token); + const tokenMarketplaceID = decodedToken.cid; + marketplaceID = tokenMarketplaceID; + + Configuration.Set({ baseApiUrl: config.OrderCloudBaseUrl }); + Tokens.SetAccessToken(org_token); + + logger(`Authenticated with client credentials to ${config.OrderCloudBaseUrl}`, MessageType.Success); + } catch (error) { + return logger(`Client credentials authentication failed: ${error.message}`, MessageType.Error); + } + } else if (userLoginAuthUsed) { + // Portal Login Flow if (_.isNil(username) || _.isNil(password)) { return logger(`Missing required arguments: username and password`, MessageType.Error) } @@ -78,46 +113,50 @@ export async function seed(args: SeedArgs): Promise { } } - // Confirm orgID doesn't already exist - try { - await portal.GetOrganization(marketplaceID, portalToken); - return logger(`A marketplace with ID \"${marketplaceID}\" already exists.`, MessageType.Error) - } catch {} - - // Create Marketplace - marketplaceName = marketplaceName || dataUrl?.split("/")?.pop()?.split(".")[0] || marketplaceID; - try - { - await portal.CreateOrganization(marketplaceID, marketplaceName, portalToken, regionId); - } - catch(exception) - { - logger(`Couldn't create marketplace with Name \"${marketplaceName}\" and ID \"${marketplaceID}\" in the region \"${regionId}\" because: \n\"${exception.response.data.Errors[0].Message}\"`, MessageType.Error); - return; - } - - logger(`Created new marketplace with Name \"${marketplaceName}\" and ID \"${marketplaceID}\".`, MessageType.Success); + if (!clientCredentialsUsed) { + // Portal-based workflow: create marketplace and get organization token + // Confirm orgID doesn't already exist + try { + await portal.GetOrganization(marketplaceID, portalToken); + return logger(`A marketplace with ID \"${marketplaceID}\" already exists.`, MessageType.Error) + } catch {} + + // Create Marketplace + marketplaceName = marketplaceName || dataUrl?.split("/")?.pop()?.split(".")[0] || marketplaceID; + try + { + await portal.CreateOrganization(marketplaceID, marketplaceName, portalToken, regionId); + } + catch(exception) + { + logger(`Couldn't create marketplace with Name \"${marketplaceName}\" and ID \"${marketplaceID}\" in the region \"${regionId}\" because: \n\"${exception.response.data.Errors[0].Message}\"`, MessageType.Error); + return; + } - var organization = await portal.GetOrganization(marketplaceID, portalToken); + logger(`Created new marketplace with Name \"${marketplaceName}\" and ID \"${marketplaceID}\".`, MessageType.Success); - if(!organization) - { - logger(`Couldn't get the newly created organization with name \"${marketplaceName}\" and ID \"${marketplaceID}\".`, MessageType.Error); - return; - } + var organization = await portal.GetOrganization(marketplaceID, portalToken); - if(!organization.CoreApiUrl.includes("sandbox")) - { - logger(`Seeding is not allowed for production accounts. Marketplace name \"${marketplaceName}\" and ID \"${marketplaceID}\".`, MessageType.Error); - return; - } + if(!organization) + { + logger(`Couldn't get the newly created organization with name \"${marketplaceName}\" and ID \"${marketplaceID}\".`, MessageType.Error); + return; + } - logger(`Seeding the newly created marketplace using api url \"${organization.CoreApiUrl}\".`, MessageType.Success); + if(!organization.CoreApiUrl.includes("sandbox")) + { + logger(`Seeding is not allowed for production accounts. Marketplace name \"${marketplaceName}\" and ID \"${marketplaceID}\".`, MessageType.Error); + return; + } + + logger(`Seeding the newly created marketplace using api url \"${organization.CoreApiUrl}\".`, MessageType.Success); - // Authenticate to Core API - var org_token = await portal.getOrganizationToken(marketplaceID, portalToken); - Configuration.Set({ baseApiUrl: organization.CoreApiUrl }); // always sandbox for upload - Tokens.SetAccessToken(org_token); + // Authenticate to Core API + org_token = await portal.getOrganizationToken(marketplaceID, portalToken); + Configuration.Set({ baseApiUrl: organization.CoreApiUrl }); // always sandbox for upload + Tokens.SetAccessToken(org_token); + } + // If clientCredentialsUsed, org_token is already set and Configuration is already set above // Upload to Ordercloud var marketplaceData = new SerializedMarketplace(validateResponse.rawData); diff --git a/src/services/config-loader.ts b/src/services/config-loader.ts new file mode 100644 index 0000000..e33d522 --- /dev/null +++ b/src/services/config-loader.ts @@ -0,0 +1,35 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export interface SeedConfig { + Name: string; + OrderCloudBaseUrl: string; + ApiClientId: string; + ApiClientSecret: string; +} + +export class ConfigLoader { + private static CONFIG_FILE_NAME = ''; + + static load(targetName: string, configPath?: string): SeedConfig { + const resolvedConfigPath = configPath + ? path.resolve(configPath) + : path.join(process.cwd(), this.CONFIG_FILE_NAME); + + if (!fs.existsSync(resolvedConfigPath)) { + throw new Error(`Configuration file not found: ${resolvedConfigPath}`); + } + + const fileContent = fs.readFileSync(resolvedConfigPath, 'utf-8'); + const configs: SeedConfig[] = JSON.parse(fileContent); + + const targetConfig = configs.find(c => c.Name === targetName); + + if (!targetConfig) { + const availableTargets = configs.map(c => c.Name).join(', '); + throw new Error(`Target "${targetName}" not found in configuration. Available targets: ${availableTargets}`); + } + + return targetConfig; + } +} diff --git a/src/services/portal.ts b/src/services/portal.ts index 738cf86..5db068f 100644 --- a/src/services/portal.ts +++ b/src/services/portal.ts @@ -1,5 +1,6 @@ import { Organizations, Auth, Organization, ApiClients, Configuration } from '@ordercloud/portal-javascript-sdk' import { PortalAuthentication } from "@ordercloud/portal-javascript-sdk/dist/models/PortalAuthentication"; +import { Auth as OrderCloudAuth, AccessToken, Configuration as OrderCloudConfiguration } from 'ordercloud-javascript-sdk'; export default class PortalAPI { constructor() { @@ -16,6 +17,13 @@ export default class PortalAPI { return await Auth.RefreshToken(refreshToken); } + async loginWithClientCredentials(clientId: string, clientSecret: string, baseUrl: string): Promise { + // Set the base URL before making the auth call + OrderCloudConfiguration.Set({ baseApiUrl: baseUrl }); + // Using FullAccess role for admin-level operations required for seeding + return await OrderCloudAuth.ClientCredentials(clientSecret, clientId, ['FullAccess']); + } + async getOrganizationToken(orgID: string, accessToken: string): Promise { return (await ApiClients.GetToken(orgID, null, { accessToken })).access_token; }