Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
2 changes: 1 addition & 1 deletion DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ on your behalf.

With Client Credentials, you need to provide the credentials (Client ID, Client Secret) configured for your OAuth client.
You can create and configure an OAuth clients in the `Admin & Settings` section of your Celonis account, under `Applications`.
The client needs to have all four scopes configured: "studio", "integration.data-pools", "action-engine.projects" and "package-manager".
The client needs to have any combination of these four scopes configured: "studio", "integration.data-pools", "action-engine.projects" and "package-manager".
After creating an OAuth client, you should assign it the permissions necessary for the respective commands. More
information on registering OAuth clients can be found [here](https://docs.celonis.com/en/registering-oauth-client.html).

Expand Down
181 changes: 130 additions & 51 deletions src/core/profile/profile.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import os = require("os");
const homedir = os.homedir();
// use 5 seconds buffer to avoid rare cases when accessToken is just about to expire before the command is sent
const expiryBuffer = 5000;
const deviceCodeScopes = ["studio", "package-manager", "integration.data-pools", "action-engine.projects"];
const clientCredentialsScopes = ["studio", "integration.data-pools", "action-engine.projects", "package-manager"];
/** All OAuth scopes; used for both device code and client credentials. */
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"];

export interface Config {
defaultProfile: string;
Expand Down Expand Up @@ -153,63 +155,65 @@ export class ProfileService {
}
break;
case ProfileType.DEVICE_CODE:
try {
const deviceCodeIssuer = await Issuer.discover(profile.team);
const deviceCodeOAuthClient = new deviceCodeIssuer.Client({
client_id: "content-cli",
token_endpoint_auth_method: "none",
});
const deviceCodeHandle = await deviceCodeOAuthClient.deviceAuthorization({
scope: deviceCodeScopes.join(" ")
});
logger.info(`Continue authorization here: ${deviceCodeHandle.verification_uri_complete}`);
const deviceCodeTokenSet = await deviceCodeHandle.poll();
profile.apiToken = deviceCodeTokenSet.access_token;
profile.refreshToken = deviceCodeTokenSet.refresh_token;
profile.expiresAt = deviceCodeTokenSet.expires_at;
} catch (err) {
logger.error(new FatalError("The provided team is wrong."));
logger.error(err);
const deviceCodeIssuer = await Issuer.discover(profile.team);
const deviceCodeOAuthClient = new deviceCodeIssuer.Client({
client_id: "content-cli",
token_endpoint_auth_method: "none",
});
const deviceCodeScopeAttempts: string[][] = [OAUTH_SCOPES, DEVICE_CODE_SCOPES_WITHOUT_ACTION_ENGINE];
let deviceCodeSuccess = false;
for (const scopeList of deviceCodeScopeAttempts) {
try {
const deviceCodeHandle = await deviceCodeOAuthClient.deviceAuthorization({
scope: scopeList.join(" "),
});
logger.info(`Continue authorization here: ${deviceCodeHandle.verification_uri_complete}`);
const deviceCodeTokenSet = await deviceCodeHandle.poll();
profile.apiToken = deviceCodeTokenSet.access_token;
profile.refreshToken = deviceCodeTokenSet.refresh_token;
profile.expiresAt = deviceCodeTokenSet.expires_at;
deviceCodeSuccess = true;
break;
} catch (err) {
// This scope set failed; try next or fail below
}
}
if (!deviceCodeSuccess) {
throw new FatalError(
"Device code authorization failed. The provided team or requested scopes may be invalid."
);
}
break;
case ProfileType.CLIENT_CREDENTIALS:
const clientCredentialsIssuer = await Issuer.discover(profile.team);
try {
// try with client secret basic
const clientCredentialsOAuthClient = new clientCredentialsIssuer.Client({
client_id: profile.clientId,
client_secret: profile.clientSecret,
token_endpoint_auth_method: ClientAuthenticationMethod.CLIENT_SECRET_BASIC,
});
const clientCredentialsTokenSet = await clientCredentialsOAuthClient.grant({
grant_type: "client_credentials",
scope: clientCredentialsScopes.join(" ")
});
const basicResult = await this.tryClientCredentialsGrant(
clientCredentialsIssuer,
profile,
ClientAuthenticationMethod.CLIENT_SECRET_BASIC
);
if (basicResult) {
profile.clientAuthenticationMethod = ClientAuthenticationMethod.CLIENT_SECRET_BASIC;
profile.apiToken = clientCredentialsTokenSet.access_token;
profile.expiresAt = clientCredentialsTokenSet.expires_at;
} catch (e) {
try {
// try with client secret post
const clientCredentialsOAuthClient = new clientCredentialsIssuer.Client({
client_id: profile.clientId,
client_secret: profile.clientSecret,
token_endpoint_auth_method: ClientAuthenticationMethod.CLIENT_SECRET_POST,
});
const clientCredentialsTokenSet = await clientCredentialsOAuthClient.grant({
grant_type: "client_credentials",
scope: clientCredentialsScopes.join(" ")
});
profile.apiToken = basicResult.tokenSet.access_token;
profile.expiresAt = basicResult.tokenSet.expires_at;
profile.scopes = basicResult.scopes;
} else {
const postResult = await this.tryClientCredentialsGrant(
clientCredentialsIssuer,
profile,
ClientAuthenticationMethod.CLIENT_SECRET_POST
);
if (postResult) {
profile.clientAuthenticationMethod = ClientAuthenticationMethod.CLIENT_SECRET_POST;
profile.apiToken = clientCredentialsTokenSet.access_token;
profile.expiresAt = clientCredentialsTokenSet.expires_at;
} catch (err) {
logger.error(new FatalError("The OAuth client configuration is incorrect. " +
"Check the id, secret and scopes for correctness."));
profile.apiToken = postResult.tokenSet.access_token;
profile.expiresAt = postResult.tokenSet.expires_at;
profile.scopes = postResult.scopes;
} else {
throw new FatalError(
"The OAuth client configuration is incorrect. " +
"Check the id, secret and allowed scopes for this client."
);
}
}
profile.scopes = [...clientCredentialsScopes];

break;
default:
logger.error(new FatalError("Unsupported profile type"));
Expand Down Expand Up @@ -320,6 +324,81 @@ export class ProfileService {
delete process.env.API_TOKEN;
}
}

/**
* Returns all non-empty combinations of scopes, ordered by size descending (4, then 3, 2, 1).
* Order within a combination does not matter; each set is tried once.
*/
private getScopeCombinationsOrderedBySize(scopes: string[]): string[][] {
function combinations<T>(arr: T[], k: number): T[][] {
if (k === 0) return [[]];
if (k > arr.length) return [];
const result: T[][] = [];
for (let i = 0; i <= arr.length - k; i++) {
const rest = combinations(arr.slice(i + 1), k - 1);
rest.forEach(r => result.push([arr[i], ...r]));
}
return result;
}
return [
...combinations(scopes, 4),
...combinations(scopes, 3),
...combinations(scopes, 2),
...combinations(scopes, 1),
];
Comment thread
ZgjimHaziri marked this conversation as resolved.
}

/**
* Tries to obtain a client_credentials token. First tries with all scopes (including
* action-engine.projects); if no token is returned, tries again without action-engine.projects.
* Returns { tokenSet, scopes } or null if no grant succeeded.
*/
private async tryClientCredentialsGrant(
issuer: import("openid-client").Issuer<import("openid-client").Client>,
Comment thread
ZgjimHaziri marked this conversation as resolved.
Outdated
profile: Profile,
tokenEndpointAuthMethod: ClientAuthenticationMethod
): Promise<{ tokenSet: { access_token: string; expires_at: number; scope?: string }; scopes: string[] } | null> {
const Client = issuer.Client as new (args: object) => {
grant: (params: { grant_type: string; scope?: string }) => Promise<{ access_token: string; expires_at: number; scope?: string }>;
};
const client = new Client({
client_id: profile.clientId,
client_secret: profile.clientSecret,
token_endpoint_auth_method: tokenEndpointAuthMethod,
});

const scopeAttempts = this.getScopeCombinationsOrderedBySize(OAUTH_SCOPES);

for (const scopeList of scopeAttempts) {
try {
const tokenSet = await client.grant({
grant_type: "client_credentials",
scope: scopeList.join(" "),
});
if (tokenSet && tokenSet.access_token) {
const scopes = this.extractScopesFromTokenSet(tokenSet, scopeList);
return { tokenSet, scopes };
}
} catch (_e) {
// This scope set failed (e.g. invalid_scope); try next
}
}
return null;
}

/**
* Extracts granted scopes from the token response (OAuth 2.0 scope parameter).
* Used at profile creation so we store the scopes the server actually granted for this client.
*/
private extractScopesFromTokenSet(tokenSet: { scope?: string }, fallbackScopes: string[]): string[] {
if (tokenSet.scope && typeof tokenSet.scope === "string") {
const scopes = tokenSet.scope.trim().split(/\s+/).filter(Boolean);
if (scopes.length > 0) {
return scopes;
}
}
return [...fallbackScopes];
}
}

export const profileService = new ProfileService();
Loading