From 631d6a19f10a4cb7dea315e125af62993ae38f54 Mon Sep 17 00:00:00 2001 From: Patrick Tamunjoh <38542007+patricktamunjoh@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:21:10 +0100 Subject: [PATCH 1/3] feat: add sdk token command --- docs/command-graph.html | 5 +- docs/user-guide/sdk-commands.md | 16 ++++ src/commands/sdk/module.ts | 10 ++ src/commands/sdk/sdk.service.ts | 19 ++++ src/core/profile/profile.service.ts | 45 +++++++++ tests/commands/sdk/module.spec.ts | 21 ++++- tests/commands/sdk/sdk-token.spec.ts | 36 ++++++++ tests/core/profile/profile.service.spec.ts | 101 +++++++++++++++++++++ 8 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 tests/commands/sdk/sdk-token.spec.ts diff --git a/docs/command-graph.html b/docs/command-graph.html index a79bd426..930af72e 100644 --- a/docs/command-graph.html +++ b/docs/command-graph.html @@ -444,6 +444,9 @@ { id: "sdk_publish", label: "publish (beta)", group: "command", path: "sdk publish", description: "Publish a build of a custom app as a new version of its Codespace asset.", options: ["-p, --profile ", "--appId ", "--dist ", "-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", @@ -546,7 +549,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_download"],["area_sdk","sdk_publish"],["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 bbbfb491..a46b396e 100644 --- a/docs/user-guide/sdk-commands.md +++ b/docs/user-guide/sdk-commands.md @@ -27,3 +27,19 @@ content-cli sdk publish --appId --dist ``` The command compresses the given build directory and uploads it for the app. On success, the version assigned to the build is printed, which can then be selected on the Codespace asset. + +## 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 267949bc..4deca2fb 100644 --- a/src/commands/sdk/module.ts +++ b/src/commands/sdk/module.ts @@ -24,6 +24,12 @@ class Module extends IModule { .requiredOption("--appId ", "Identifier of the app to publish the build for") .requiredOption("--dist ", "Directory containing the build of the app") .action(this.publishApp); + + 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 { @@ -33,6 +39,10 @@ class Module extends IModule { private async publishApp(context: Context, command: Command, options: OptionValues): Promise { await new SdkService(context).publishApp(options.appId, options.dist); } + + 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..add7a13e 100644 --- a/src/commands/sdk/sdk.service.ts +++ b/src/commands/sdk/sdk.service.ts @@ -4,13 +4,18 @@ 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"; + public static readonly SDK_READ_SCOPE = "pro-code-app-manager.sdk:read"; + private readonly context: Context; private readonly sdkApi: SdkApi; constructor(context: Context) { + this.context = context; this.sdkApi = new SdkApi(context); } @@ -37,6 +42,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().acquireScopedToken(profile, [SdkService.SDK_READ_SCOPE]); + + 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..3fc2b1da 100644 --- a/src/core/profile/profile.service.ts +++ b/src/core/profile/profile.service.ts @@ -322,6 +322,51 @@ export class ProfileService { await this.storeProfile(profile); } + public async acquireScopedToken(profile: Profile, scopes: string[]): Promise { + switch (profile.type) { + case ProfileType.DEVICE_CODE: + return this.acquireScopedDeviceCodeToken(profile, scopes); + case ProfileType.CLIENT_CREDENTIALS: + return this.acquireScopedClientCredentialsToken(profile, scopes); + default: + throw new FatalError( + "A scoped token cannot be acquired for API key profiles. Use the API key directly for authentication." + ); + } + } + + private async acquireScopedDeviceCodeToken(profile: Profile, scopes: string[]): 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: scopes.join(" ") }, + }); + + profile.refreshToken = tokenSet.refresh_token; + profile.expiresAt = 0; + await this.storeProfile(profile); + + return tokenSet.access_token; + } + + private async acquireScopedClientCredentialsToken(profile: Profile, scopes: string[]): 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: scopes.join(" "), + }); + + 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 fb7aeac0..988063f8 100644 --- a/tests/commands/sdk/module.spec.ts +++ b/tests/commands/sdk/module.spec.ts @@ -14,7 +14,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(3); + expect(mockConfigurator.beta).toHaveBeenCalledTimes(4); }); it("registers the sdk publish command", () => { @@ -47,5 +47,24 @@ describe("SDK Module", () => { expect(publishApp).toHaveBeenCalledWith("app-id", "dist"); }); + + 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[2][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..bd9cfa21 --- /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 acquireScopedToken: jest.SpyInstance; + let stdoutWrite: jest.SpyInstance; + + beforeEach(() => { + acquireScopedToken = jest.spyOn(ProfileService.prototype, "acquireScopedToken").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 token with the SDK read scope only", async () => { + await new SdkService(testContext).printToken(); + + expect(acquireScopedToken).toHaveBeenCalledWith(testContext.profile, ["pro-code-app-manager.sdk:read"]); + }); + + it("Should fail when no profile is available", async () => { + await expect(new SdkService(new Context({})).printToken()).rejects.toThrow("No profile provided"); + + expect(acquireScopedToken).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/core/profile/profile.service.spec.ts b/tests/core/profile/profile.service.spec.ts index 2ab4a750..659d18cc 100644 --- a/tests/core/profile/profile.service.spec.ts +++ b/tests/core/profile/profile.service.spec.ts @@ -1039,6 +1039,107 @@ describe("ProfileService - authorizeProfile (client credentials)", () => { }); }); +describe("ProfileService - acquireScopedToken", () => { + 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 requested scopes only", async () => { + const token = await profileService.acquireScopedToken(createProfile(ProfileType.DEVICE_CODE), ["sdk:read"]); + + expect(token).toBe("scoped-token"); + expect(mockClientInstance.refresh).toHaveBeenCalledWith("refresh-old", { + exchangeBody: { scope: "sdk:read" }, + }); + }); + + it("should store the rotated refresh token and mark the profile as expired", async () => { + const profile = createProfile(ProfileType.DEVICE_CODE); + + await profileService.acquireScopedToken(profile, ["sdk:read"]); + + 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.acquireScopedToken(profile, ["sdk:read"]); + + expect(profile.apiToken).toBe("full-scope-token"); + }); + }); + + describe("for client credentials profiles", () => { + it("should grant a token with the requested scopes only", async () => { + const token = await profileService.acquireScopedToken(createProfile(ProfileType.CLIENT_CREDENTIALS), ["sdk:read"]); + + 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: "sdk:read", + }); + }); + + it("should leave the profile unchanged", async () => { + const profile = createProfile(ProfileType.CLIENT_CREDENTIALS); + const originalProfile = { ...profile }; + + await profileService.acquireScopedToken(profile, ["sdk:read"]); + + 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.acquireScopedToken(createProfile(ProfileType.KEY), ["sdk:read"]) + ).rejects.toThrow("Use the API key directly for authentication"); + + expect(mockIssuerDiscover).not.toHaveBeenCalled(); + }); + }); +}); + describe("ProfileService - makeDefaultProfile", () => { let profileService: ProfileService; const mockHomedir = "/mock/home"; From 3fd4bd227d30b1a47d79e6ed934ce07fbeff4690 Mon Sep 17 00:00:00 2001 From: Patrick Tamunjoh <38542007+patricktamunjoh@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:25:46 +0100 Subject: [PATCH 2/3] refactor: restrict token acquisition to sdk scope --- src/commands/sdk/sdk.service.ts | 3 +-- src/core/profile/profile.service.ts | 19 ++++++++++--------- tests/commands/sdk/sdk-token.spec.ts | 10 +++++----- tests/core/profile/profile.service.spec.ts | 22 +++++++++++----------- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/commands/sdk/sdk.service.ts b/src/commands/sdk/sdk.service.ts index add7a13e..dbe9e653 100644 --- a/src/commands/sdk/sdk.service.ts +++ b/src/commands/sdk/sdk.service.ts @@ -9,7 +9,6 @@ import { Profile } from "../../core/profile/profile.interface"; export class SdkService { public static readonly SDK_DIRECTORY = ".celonis-sdk"; - public static readonly SDK_READ_SCOPE = "pro-code-app-manager.sdk:read"; private readonly context: Context; private readonly sdkApi: SdkApi; @@ -44,7 +43,7 @@ export class SdkService { public async printToken(): Promise { const profile = this.requireProfile(); - const token = await new ProfileService().acquireScopedToken(profile, [SdkService.SDK_READ_SCOPE]); + const token = await new ProfileService().acquireSdkToken(profile); process.stdout.write(`${token}\n`); } diff --git a/src/core/profile/profile.service.ts b/src/core/profile/profile.service.ts index 3fc2b1da..426542e6 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_READ_SCOPE = "pro-code-app-manager.sdk:read"; /** 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_READ_SCOPE, "pro-code-app-manager.publish"]; export interface Config { defaultProfile: string; @@ -322,27 +323,27 @@ export class ProfileService { await this.storeProfile(profile); } - public async acquireScopedToken(profile: Profile, scopes: string[]): Promise { + public async acquireSdkToken(profile: Profile): Promise { switch (profile.type) { case ProfileType.DEVICE_CODE: - return this.acquireScopedDeviceCodeToken(profile, scopes); + return this.acquireDeviceCodeSdkToken(profile); case ProfileType.CLIENT_CREDENTIALS: - return this.acquireScopedClientCredentialsToken(profile, scopes); + return this.acquireClientCredentialsSdkToken(profile); default: throw new FatalError( - "A scoped token cannot be acquired for API key profiles. Use the API key directly for authentication." + "An SDK token cannot be acquired for API key profiles. Use the API key directly for authentication." ); } } - private async acquireScopedDeviceCodeToken(profile: Profile, scopes: string[]): Promise { + 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: scopes.join(" ") }, + exchangeBody: { scope: SDK_READ_SCOPE }, }); profile.refreshToken = tokenSet.refresh_token; @@ -352,7 +353,7 @@ export class ProfileService { return tokenSet.access_token; } - private async acquireScopedClientCredentialsToken(profile: Profile, scopes: string[]): Promise { + private async acquireClientCredentialsSdkToken(profile: Profile): Promise { const issuer = await Issuer.discover(profile.team); const oauthClient = new issuer.Client({ client_id: profile.clientId, @@ -361,7 +362,7 @@ export class ProfileService { }); const tokenSet = await oauthClient.grant({ grant_type: "client_credentials", - scope: scopes.join(" "), + scope: SDK_READ_SCOPE, }); return tokenSet.access_token; diff --git a/tests/commands/sdk/sdk-token.spec.ts b/tests/commands/sdk/sdk-token.spec.ts index bd9cfa21..93a5af9b 100644 --- a/tests/commands/sdk/sdk-token.spec.ts +++ b/tests/commands/sdk/sdk-token.spec.ts @@ -4,11 +4,11 @@ import { Context } from "../../../src/core/command/cli-context"; import { testContext } from "../../utls/test-context"; describe("SDK token", () => { - let acquireScopedToken: jest.SpyInstance; + let acquireSdkToken: jest.SpyInstance; let stdoutWrite: jest.SpyInstance; beforeEach(() => { - acquireScopedToken = jest.spyOn(ProfileService.prototype, "acquireScopedToken").mockResolvedValue("sdk-token"); + acquireSdkToken = jest.spyOn(ProfileService.prototype, "acquireSdkToken").mockResolvedValue("sdk-token"); stdoutWrite = jest.spyOn(process.stdout, "write").mockImplementation(() => true); }); @@ -22,15 +22,15 @@ describe("SDK token", () => { expect(stdoutWrite).toHaveBeenCalledWith("sdk-token\n"); }); - it("Should acquire the token with the SDK read scope only", async () => { + it("Should acquire the SDK token for the current profile", async () => { await new SdkService(testContext).printToken(); - expect(acquireScopedToken).toHaveBeenCalledWith(testContext.profile, ["pro-code-app-manager.sdk:read"]); + 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(acquireScopedToken).not.toHaveBeenCalled(); + expect(acquireSdkToken).not.toHaveBeenCalled(); }); }); diff --git a/tests/core/profile/profile.service.spec.ts b/tests/core/profile/profile.service.spec.ts index 659d18cc..109e0c08 100644 --- a/tests/core/profile/profile.service.spec.ts +++ b/tests/core/profile/profile.service.spec.ts @@ -1039,7 +1039,7 @@ describe("ProfileService - authorizeProfile (client credentials)", () => { }); }); -describe("ProfileService - acquireScopedToken", () => { +describe("ProfileService - acquireSdkToken", () => { let profileService: ProfileService; let mockClientInstance: { refresh: jest.Mock; grant: jest.Mock }; let mockClientConstructor: jest.Mock; @@ -1074,19 +1074,19 @@ describe("ProfileService - acquireScopedToken", () => { }); describe("for device code profiles", () => { - it("should refresh with the requested scopes only", async () => { - const token = await profileService.acquireScopedToken(createProfile(ProfileType.DEVICE_CODE), ["sdk:read"]); + it("should refresh with the SDK read 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: "sdk:read" }, + exchangeBody: { scope: "pro-code-app-manager.sdk:read" }, }); }); it("should store the rotated refresh token and mark the profile as expired", async () => { const profile = createProfile(ProfileType.DEVICE_CODE); - await profileService.acquireScopedToken(profile, ["sdk:read"]); + await profileService.acquireSdkToken(profile); expect(storeProfileSpy).toHaveBeenCalledWith(profile); expect(profile.refreshToken).toBe("refresh-new"); @@ -1096,15 +1096,15 @@ describe("ProfileService - acquireScopedToken", () => { it("should not store the scoped token in the profile", async () => { const profile = createProfile(ProfileType.DEVICE_CODE); - await profileService.acquireScopedToken(profile, ["sdk:read"]); + await profileService.acquireSdkToken(profile); expect(profile.apiToken).toBe("full-scope-token"); }); }); describe("for client credentials profiles", () => { - it("should grant a token with the requested scopes only", async () => { - const token = await profileService.acquireScopedToken(createProfile(ProfileType.CLIENT_CREDENTIALS), ["sdk:read"]); + it("should grant a token with the SDK read scope only", async () => { + const token = await profileService.acquireSdkToken(createProfile(ProfileType.CLIENT_CREDENTIALS)); expect(token).toBe("scoped-token"); expect(mockClientConstructor).toHaveBeenCalledWith({ @@ -1114,7 +1114,7 @@ describe("ProfileService - acquireScopedToken", () => { }); expect(mockClientInstance.grant).toHaveBeenCalledWith({ grant_type: "client_credentials", - scope: "sdk:read", + scope: "pro-code-app-manager.sdk:read", }); }); @@ -1122,7 +1122,7 @@ describe("ProfileService - acquireScopedToken", () => { const profile = createProfile(ProfileType.CLIENT_CREDENTIALS); const originalProfile = { ...profile }; - await profileService.acquireScopedToken(profile, ["sdk:read"]); + await profileService.acquireSdkToken(profile); expect(profile).toEqual(originalProfile); expect(storeProfileSpy).not.toHaveBeenCalled(); @@ -1132,7 +1132,7 @@ describe("ProfileService - acquireScopedToken", () => { describe("for API key profiles", () => { it("should fail and point to using the API key directly", async () => { await expect( - profileService.acquireScopedToken(createProfile(ProfileType.KEY), ["sdk:read"]) + profileService.acquireSdkToken(createProfile(ProfileType.KEY)) ).rejects.toThrow("Use the API key directly for authentication"); expect(mockIssuerDiscover).not.toHaveBeenCalled(); From 42576c5d3ae439d1d4582934c5c6ddc40ae70342 Mon Sep 17 00:00:00 2001 From: Patrick Tamunjoh <38542007+patricktamunjoh@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:53:42 +0100 Subject: [PATCH 3/3] refactor: rename sdk scope --- src/core/profile/profile.service.ts | 8 ++++---- tests/core/profile/profile.service.spec.ts | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/core/profile/profile.service.ts b/src/core/profile/profile.service.ts index 426542e6..db8bf0a9 100644 --- a/src/core/profile/profile.service.ts +++ b/src/core/profile/profile.service.ts @@ -19,9 +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_READ_SCOPE = "pro-code-app-manager.sdk:read"; +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, SDK_READ_SCOPE, "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; @@ -343,7 +343,7 @@ export class ProfileService { token_endpoint_auth_method: "none", }); const tokenSet = await oauthClient.refresh(profile.refreshToken, { - exchangeBody: { scope: SDK_READ_SCOPE }, + exchangeBody: { scope: SDK_SCOPE }, }); profile.refreshToken = tokenSet.refresh_token; @@ -362,7 +362,7 @@ export class ProfileService { }); const tokenSet = await oauthClient.grant({ grant_type: "client_credentials", - scope: SDK_READ_SCOPE, + scope: SDK_SCOPE, }); return tokenSet.access_token; diff --git a/tests/core/profile/profile.service.spec.ts b/tests/core/profile/profile.service.spec.ts index 109e0c08..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", ]); @@ -1074,12 +1074,12 @@ describe("ProfileService - acquireSdkToken", () => { }); describe("for device code profiles", () => { - it("should refresh with the SDK read scope only", async () => { + 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:read" }, + exchangeBody: { scope: "pro-code-app-manager.sdk" }, }); }); @@ -1103,7 +1103,7 @@ describe("ProfileService - acquireSdkToken", () => { }); describe("for client credentials profiles", () => { - it("should grant a token with the SDK read scope only", async () => { + 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"); @@ -1114,7 +1114,7 @@ describe("ProfileService - acquireSdkToken", () => { }); expect(mockClientInstance.grant).toHaveBeenCalledWith({ grant_type: "client_credentials", - scope: "pro-code-app-manager.sdk:read", + scope: "pro-code-app-manager.sdk", }); });