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
5 changes: 4 additions & 1 deletion docs/command-graph.html
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,9 @@
{ id: "sdk_preview", label: "preview (beta)", group: "command", path: "sdk preview",
description: "Serve a local dev server to the Codespace's Local preview with the published app's sandbox rules",
options: ["--target <target>", "--port <port>", "--allow-inline-scripts", "-h, --help"] },
{ id: "sdk_token", label: "token (beta)", group: "command", path: "sdk token",
description: "Print a token with read access to the Celonis SDK.",
options: ["-p, --profile <profile>", "-h, --help"] },

// git profile
{ id: "git_profile_area", label: "profile", group: "subarea", path: "git profile",
Expand Down Expand Up @@ -549,7 +552,7 @@
["deployment_list_area","deployment_list_history"],["deployment_list_area","deployment_list_active"],
["deployment_list_area","deployment_list_deployables"],["deployment_list_area","deployment_list_targets"],

["area_sdk","sdk_download"],["area_sdk","sdk_publish"],["area_sdk","sdk_preview"],
["area_sdk","sdk_download"],["area_sdk","sdk_publish"],["area_sdk","sdk_preview"],["area_sdk","sdk_token"],

["area_git","git_profile_area"],["git_profile_area","git_profile_list"],
["git_profile_area","git_profile_create"],["git_profile_area","git_profile_default"],
Expand Down
16 changes: 16 additions & 0 deletions docs/user-guide/sdk-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,19 @@ content-cli sdk preview [--target <target>] [--port <port>] [--allow-inline-scri
The command does not need a profile. It serves your dev server over HTTPS with a self-signed certificate stored in `~/.celonis-content-cli-preview`, and forwards requests and WebSocket connections to the target. If the Local preview stays blank, open the printed `https://localhost:<port>` URL in a browser tab, accept the certificate, and reload the preview. Press Ctrl+C to stop.

Published apps block inline scripts, and so does the Local preview by default. Some dev servers inject one, such as React's Fast Refresh preamble, and the app stays blank without it. Pass `--allow-inline-scripts` to allow them: the command adds a hash of each inline script to the page's `script-src`. Either way it prints a warning for each inline script the first time it sees it. Move any inline script of your own into a separate file before publishing.

## Print Token

Print a fresh token that only grants read access to the Celonis SDK, e.g. to authenticate against the npm registry.

```
content-cli -q sdk token
```

The token is printed to the console without any other output when the `-q` option is set, so it can be used in scripts, e.g. to configure npm:

```
npm config set //<team>.celonis.cloud/pro-code-app-manager/api/npm/:_authToken "$(content-cli -q sdk token)"
```

The command requires a device code or client credentials profile. API keys can be used directly for authentication instead.
10 changes: 10 additions & 0 deletions src/commands/sdk/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ class Module extends IModule {
.option("--port <port>", "Target port on which the preview is hosted", "4300")
.option("--allow-inline-scripts", "Allow inline scripts in your dev server's HTML")
.action(this.previewApp);

sdkCommand
.command("token")
.beta()
.description("Print a token with read access to the Celonis SDK, e.g. to authenticate against the npm registry")
.action(this.printToken);
}

private async downloadSdk(context: Context, command: Command, options: OptionValues): Promise<void> {
Expand All @@ -48,6 +54,10 @@ class Module extends IModule {
private async previewApp(context: Context, command: Command, options: OptionValues): Promise<void> {
await new SdkPreviewService().previewUntilStopped(options.target, options.port, options.allowInlineScripts === true);
}

private async printToken(context: Context): Promise<void> {
await new SdkService(context).printToken();
}
}

export = Module;
18 changes: 18 additions & 0 deletions src/commands/sdk/sdk.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ import AdmZip = require("adm-zip");
import { SdkApi } from "./sdk-api";
import { Context } from "../../core/command/cli-context";
import { FatalError, logger } from "../../core/utils/logger";
import { ProfileService } from "../../core/profile/profile.service";
import { Profile } from "../../core/profile/profile.interface";

export class SdkService {
public static readonly SDK_DIRECTORY = ".celonis-sdk";

private readonly context: Context;
private readonly sdkApi: SdkApi;

constructor(context: Context) {
this.context = context;
this.sdkApi = new SdkApi(context);
}

Expand All @@ -37,6 +41,20 @@ export class SdkService {
logger.info(`App published successfully as version '${version}'`);
}

public async printToken(): Promise<void> {
const profile = this.requireProfile();
const token = await new ProfileService().acquireSdkToken(profile);

process.stdout.write(`${token}\n`);
}

private requireProfile(): Profile {
if (!this.context.profile) {
throw new FatalError("No profile provided. Please provide a profile to acquire a token for");
}
return this.context.profile;
}

private requireDirectory(directory: string): void {
if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) {
throw new FatalError(`The dist directory ${directory} does not exist or is no directory`);
Expand Down
48 changes: 47 additions & 1 deletion src/core/profile/profile.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ const expiryBuffer = 5000;
const OAUTH_SCOPES = ["studio", "package-manager", "integration.data-pools", "action-engine.projects"];
/** Device code fallback: try without action-engine.projects if all 4 scopes fail. */
const DEVICE_CODE_SCOPES_WITHOUT_ACTION_ENGINE = ["studio", "package-manager", "integration.data-pools"];
const SDK_SCOPE = "pro-code-app-manager.sdk";
/** First attempt for both auth flows: all scopes plus the pro-code-app-manager scopes, which are not yet available on every environment. */
const OAUTH_SCOPES_WITH_PRO_CODE = [...OAUTH_SCOPES, "pro-code-app-manager.sdk:read", "pro-code-app-manager.publish"];
const OAUTH_SCOPES_WITH_PRO_CODE = [...OAUTH_SCOPES, SDK_SCOPE, "pro-code-app-manager.publish"];

export interface Config {
defaultProfile: string;
Expand Down Expand Up @@ -322,6 +323,51 @@ export class ProfileService {
await this.storeProfile(profile);
}

public async acquireSdkToken(profile: Profile): Promise<string> {
switch (profile.type) {
case ProfileType.DEVICE_CODE:
return this.acquireDeviceCodeSdkToken(profile);
case ProfileType.CLIENT_CREDENTIALS:
return this.acquireClientCredentialsSdkToken(profile);
default:
throw new FatalError(
"An SDK token cannot be acquired for API key profiles. Use the API key directly for authentication."
);
}
}

private async acquireDeviceCodeSdkToken(profile: Profile): Promise<string> {
const issuer = await Issuer.discover(profile.team);
const oauthClient = new issuer.Client({
client_id: "content-cli",
token_endpoint_auth_method: "none",
});
const tokenSet = await oauthClient.refresh(profile.refreshToken, {
exchangeBody: { scope: SDK_SCOPE },
});

profile.refreshToken = tokenSet.refresh_token;
profile.expiresAt = 0;
await this.storeProfile(profile);

return tokenSet.access_token;
}

private async acquireClientCredentialsSdkToken(profile: Profile): Promise<string> {
const issuer = await Issuer.discover(profile.team);
const oauthClient = new issuer.Client({
client_id: profile.clientId,
client_secret: profile.clientSecret,
token_endpoint_auth_method: profile.clientAuthenticationMethod,
});
const tokenSet = await oauthClient.grant({
grant_type: "client_credentials",
scope: SDK_SCOPE,
});

return tokenSet.access_token;
}

private unrefreshableProfile(profileName: string, err: unknown): Error {
const cause = err instanceof Error ? err.message : "an unrecognised error";
logger.debug(`Refreshing the profile ${profileName} failed: ${cause}`);
Expand Down
21 changes: 20 additions & 1 deletion tests/commands/sdk/module.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ describe("SDK Module", () => {
expect(mockConfigurator.command).toHaveBeenCalledWith("sdk");
expect(mockConfigurator.command).toHaveBeenCalledWith("download");
expect(mockConfigurator.requiredOption).toHaveBeenCalledWith("--appId <appId>", expect.any(String));
expect(mockConfigurator.beta).toHaveBeenCalledTimes(4);
expect(mockConfigurator.beta).toHaveBeenCalledTimes(5);
});

it("registers the sdk publish command", () => {
Expand Down Expand Up @@ -83,5 +83,24 @@ describe("SDK Module", () => {
expect(process.listenerCount("SIGINT")).toBe(sigintListeners);
expect(process.listenerCount("SIGTERM")).toBe(sigtermListeners);
});

it("registers the sdk token command", () => {
const mockConfigurator = createMockConfigurator();

new Module().register(testContext, mockConfigurator);

expect(mockConfigurator.command).toHaveBeenCalledWith("token");
});

it("prints the token when the token command runs", async () => {
const printToken = jest.spyOn(SdkService.prototype, "printToken").mockResolvedValue();
const mockConfigurator = createMockConfigurator();
new Module().register(testContext, mockConfigurator);

const tokenAction = mockConfigurator.action.mock.calls[3][0];
await tokenAction(testContext, new Command(), {});

expect(printToken).toHaveBeenCalled();
});
});
});
36 changes: 36 additions & 0 deletions tests/commands/sdk/sdk-token.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { SdkService } from "../../../src/commands/sdk/sdk.service";
import { ProfileService } from "../../../src/core/profile/profile.service";
import { Context } from "../../../src/core/command/cli-context";
import { testContext } from "../../utls/test-context";

describe("SDK token", () => {
let acquireSdkToken: jest.SpyInstance;
let stdoutWrite: jest.SpyInstance;

beforeEach(() => {
acquireSdkToken = jest.spyOn(ProfileService.prototype, "acquireSdkToken").mockResolvedValue("sdk-token");
stdoutWrite = jest.spyOn(process.stdout, "write").mockImplementation(() => true);
});

afterEach(() => {
jest.restoreAllMocks();
});

it("Should print the acquired token", async () => {
await new SdkService(testContext).printToken();

expect(stdoutWrite).toHaveBeenCalledWith("sdk-token\n");
});

it("Should acquire the SDK token for the current profile", async () => {
await new SdkService(testContext).printToken();

expect(acquireSdkToken).toHaveBeenCalledWith(testContext.profile);
});

it("Should fail when no profile is available", async () => {
await expect(new SdkService(new Context({})).printToken()).rejects.toThrow("No profile provided");

expect(acquireSdkToken).not.toHaveBeenCalled();
});
});
105 changes: 103 additions & 2 deletions tests/core/profile/profile.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -874,7 +874,7 @@ describe("ProfileService - tryClientCredentialsGrant", () => {
expect(result).not.toBeNull();
expect(result!.scopes).toEqual([
"studio", "package-manager", "integration.data-pools", "action-engine.projects",
"pro-code-app-manager.sdk:read", "pro-code-app-manager.publish",
"pro-code-app-manager.sdk", "pro-code-app-manager.publish",
]);
expect(mockGrant).toHaveBeenCalledTimes(1);
});
Expand Down Expand Up @@ -950,7 +950,7 @@ describe("ProfileService - authorizeProfile (device code)", () => {

expect(mockClientInstance.deviceAuthorization.mock.calls.map(([params]) => params.scope)).toEqual([
"studio package-manager integration.data-pools action-engine.projects " +
"pro-code-app-manager.sdk:read pro-code-app-manager.publish",
"pro-code-app-manager.sdk pro-code-app-manager.publish",
"studio package-manager integration.data-pools action-engine.projects",
"studio package-manager integration.data-pools",
]);
Expand Down Expand Up @@ -1039,6 +1039,107 @@ describe("ProfileService - authorizeProfile (client credentials)", () => {
});
});

describe("ProfileService - acquireSdkToken", () => {
let profileService: ProfileService;
let mockClientInstance: { refresh: jest.Mock; grant: jest.Mock };
let mockClientConstructor: jest.Mock;
let storeProfileSpy: jest.SpyInstance;

function createProfile(type: ProfileType): Profile {
return {
name: "test",
team: "https://example.celonis.cloud",
apiToken: "full-scope-token",
refreshToken: "refresh-old",
expiresAt: Math.floor(Date.now() / 1000) + 3600,
authenticationType: AuthenticationType.BEARER,
type,
clientId: "client-id",
clientSecret: "client-secret",
clientAuthenticationMethod: "client_secret_basic",
scopes: ["studio", "package-manager"],
};
}

beforeEach(() => {
profileService = new ProfileService();
mockIssuerDiscover.mockReset();
mockClientInstance = {
refresh: jest.fn().mockResolvedValue({ access_token: "scoped-token", refresh_token: "refresh-new" }),
grant: jest.fn().mockResolvedValue({ access_token: "scoped-token" }),
};
mockClientConstructor = jest.fn().mockReturnValue(mockClientInstance);
mockIssuerDiscover.mockResolvedValue({ Client: mockClientConstructor });
storeProfileSpy = jest.spyOn(profileService, "storeProfile").mockResolvedValue();
});

describe("for device code profiles", () => {
it("should refresh with the SDK scope only", async () => {
const token = await profileService.acquireSdkToken(createProfile(ProfileType.DEVICE_CODE));

expect(token).toBe("scoped-token");
expect(mockClientInstance.refresh).toHaveBeenCalledWith("refresh-old", {
exchangeBody: { scope: "pro-code-app-manager.sdk" },
});
});

it("should store the rotated refresh token and mark the profile as expired", async () => {
const profile = createProfile(ProfileType.DEVICE_CODE);

await profileService.acquireSdkToken(profile);

expect(storeProfileSpy).toHaveBeenCalledWith(profile);
expect(profile.refreshToken).toBe("refresh-new");
expect(profile.expiresAt).toBe(0);
});

it("should not store the scoped token in the profile", async () => {
const profile = createProfile(ProfileType.DEVICE_CODE);

await profileService.acquireSdkToken(profile);

expect(profile.apiToken).toBe("full-scope-token");
});
});

describe("for client credentials profiles", () => {
it("should grant a token with the SDK scope only", async () => {
const token = await profileService.acquireSdkToken(createProfile(ProfileType.CLIENT_CREDENTIALS));

expect(token).toBe("scoped-token");
expect(mockClientConstructor).toHaveBeenCalledWith({
client_id: "client-id",
client_secret: "client-secret",
token_endpoint_auth_method: "client_secret_basic",
});
expect(mockClientInstance.grant).toHaveBeenCalledWith({
grant_type: "client_credentials",
scope: "pro-code-app-manager.sdk",
});
});

it("should leave the profile unchanged", async () => {
const profile = createProfile(ProfileType.CLIENT_CREDENTIALS);
const originalProfile = { ...profile };

await profileService.acquireSdkToken(profile);

expect(profile).toEqual(originalProfile);
expect(storeProfileSpy).not.toHaveBeenCalled();
});
});

describe("for API key profiles", () => {
it("should fail and point to using the API key directly", async () => {
await expect(
profileService.acquireSdkToken(createProfile(ProfileType.KEY))
).rejects.toThrow("Use the API key directly for authentication");

expect(mockIssuerDiscover).not.toHaveBeenCalled();
});
});
});

describe("ProfileService - makeDefaultProfile", () => {
let profileService: ProfileService;
const mockHomedir = "/mock/home";
Expand Down
Loading