diff --git a/docs/command-graph.html b/docs/command-graph.html index 309b4b34..24dd3b6b 100644 --- a/docs/command-graph.html +++ b/docs/command-graph.html @@ -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 ", "--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 ", "-h, --help"] }, // git profile { id: "git_profile_area", label: "profile", group: "subarea", path: "git profile", @@ -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"], diff --git a/docs/user-guide/sdk-commands.md b/docs/user-guide/sdk-commands.md index 0ee2531f..6396e50a 100644 --- a/docs/user-guide/sdk-commands.md +++ b/docs/user-guide/sdk-commands.md @@ -43,3 +43,19 @@ content-cli sdk preview [--target ] [--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:` 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 //.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. diff --git a/src/commands/sdk/module.ts b/src/commands/sdk/module.ts index aca85c38..67094dae 100644 --- a/src/commands/sdk/module.ts +++ b/src/commands/sdk/module.ts @@ -35,6 +35,12 @@ class Module extends IModule { .option("--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 { @@ -48,6 +54,10 @@ class Module extends IModule { private async previewApp(context: Context, command: Command, options: OptionValues): Promise { await new SdkPreviewService().previewUntilStopped(options.target, options.port, options.allowInlineScripts === true); } + + private async printToken(context: Context): Promise { + await new SdkService(context).printToken(); + } } export = Module; diff --git a/src/commands/sdk/sdk.service.ts b/src/commands/sdk/sdk.service.ts index f6481ac7..dbe9e653 100644 --- a/src/commands/sdk/sdk.service.ts +++ b/src/commands/sdk/sdk.service.ts @@ -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); } @@ -37,6 +41,20 @@ export class SdkService { logger.info(`App published successfully as version '${version}'`); } + public async printToken(): Promise { + 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`); diff --git a/src/core/profile/profile.service.ts b/src/core/profile/profile.service.ts index 225c78f4..db8bf0a9 100644 --- a/src/core/profile/profile.service.ts +++ b/src/core/profile/profile.service.ts @@ -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; @@ -322,6 +323,51 @@ export class ProfileService { await this.storeProfile(profile); } + public async acquireSdkToken(profile: Profile): Promise { + 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 { + 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 { + 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}`); diff --git a/tests/commands/sdk/module.spec.ts b/tests/commands/sdk/module.spec.ts index 9121abcd..84c80250 100644 --- a/tests/commands/sdk/module.spec.ts +++ b/tests/commands/sdk/module.spec.ts @@ -16,7 +16,7 @@ describe("SDK Module", () => { expect(mockConfigurator.command).toHaveBeenCalledWith("sdk"); expect(mockConfigurator.command).toHaveBeenCalledWith("download"); expect(mockConfigurator.requiredOption).toHaveBeenCalledWith("--appId ", expect.any(String)); - expect(mockConfigurator.beta).toHaveBeenCalledTimes(4); + expect(mockConfigurator.beta).toHaveBeenCalledTimes(5); }); it("registers the sdk publish command", () => { @@ -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(); + }); }); }); diff --git a/tests/commands/sdk/sdk-token.spec.ts b/tests/commands/sdk/sdk-token.spec.ts new file mode 100644 index 00000000..93a5af9b --- /dev/null +++ b/tests/commands/sdk/sdk-token.spec.ts @@ -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(); + }); +}); diff --git a/tests/core/profile/profile.service.spec.ts b/tests/core/profile/profile.service.spec.ts index 2ab4a750..96cccacf 100644 --- a/tests/core/profile/profile.service.spec.ts +++ b/tests/core/profile/profile.service.spec.ts @@ -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); }); @@ -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", ]); @@ -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";