Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ID-1462] add OAuth support #163

Merged
merged 36 commits into from
Jul 10, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
45f590f
wip
pavelefros Dec 19, 2023
60c47da
merge master
pavelefros Dec 20, 2023
a59be53
add OAuth support with device code flow
pavelefros Dec 21, 2023
bce0e91
fix refreshing profile
pavelefros Dec 21, 2023
3f3d5a0
update documentation
pavelefros Dec 21, 2023
42c8e1f
small refactorings
pavelefros Dec 21, 2023
874f0d2
fix format
pavelefros Dec 21, 2023
c6329a2
fix tests
pavelefros Dec 21, 2023
67eac80
add support for OAuth Client Credentials
pavelefros Dec 21, 2023
c9042f8
add action-engine scope
pavelefros Dec 22, 2023
8b4f324
save scopes for client_credentials profile for compatibility
pavelefros Jan 3, 2024
49cbde9
improve error handling
pavelefros Jan 3, 2024
35f2448
Merge branch 'master' into ID-1462-add-OAuth-support
pavelefros Feb 6, 2024
2c88d37
Merge branch 'master' into ID-1462-add-OAuth-support
pavelefros Feb 23, 2024
ccc9d15
update scopes
pavelefros Feb 23, 2024
65660e2
Merge branch 'master' into ID-1462-add-OAuth-support
pavelefros Mar 6, 2024
b9ad39c
Merge branch 'master' into ID-1462-add-OAuth-support
pavelefros Mar 18, 2024
059e84a
use specific scopes for device code and client credentials
pavelefros Mar 19, 2024
2759b5d
auto-detect client authentication method
pavelefros Mar 19, 2024
386d203
fix base url in analysis-bookmarks.manager.ts
pavelefros Mar 19, 2024
857ee85
clean up objective command and related
pavelefros Mar 20, 2024
a4971d4
remove transformation-center scopes
pavelefros Mar 20, 2024
e1d0a47
remove unused widget-sourcemaps command and related
pavelefros Mar 21, 2024
056a4a8
update DOCUMENTATION.md
pavelefros Mar 22, 2024
8b716de
remove datadog dependency and tracing.ts
pavelefros Apr 2, 2024
59207f1
add missing uuid and types
pavelefros Apr 2, 2024
3627448
Revert "add missing uuid and types"
pavelefros Apr 4, 2024
415a8e7
Revert "remove datadog dependency and tracing.ts"
pavelefros Apr 4, 2024
e5dc115
merge master
pavelefros Jun 4, 2024
b266d65
increase minor version
pavelefros Jun 27, 2024
7626d5f
add link to OAuth documentation
pavelefros Jun 27, 2024
edbcae3
add constants
pavelefros Jun 27, 2024
b27d44f
Merge branch 'master' into ID-1462-add-OAuth-support
pavelefros Jun 27, 2024
e668958
fix authorizeProfile with Keys
pavelefros Jul 3, 2024
eab2ec5
backwards compatibility
pavelefros Jul 5, 2024
f89fe2a
fix skillmanager base_url
pavelefros Jul 9, 2024
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
23 changes: 22 additions & 1 deletion DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,28 @@ your profiles by running the ***content-cli profile list*** command.
| Note: Please do not use blanks in profile names |
|-------------------------------------------------|

#### API Token
#### Profile Types
You can create profiles of two types: using OAuth (Device Code
or Client Credentials) or using API Tokens (Application Key / API Key):

##### OAuth

OAuth supports with two grant types: Device Code & Client Credentials.

With Device Code, creating the profile will trigger an authorization flow
(using the OAuth 2.0 Device code). You will be prompted to follow an authorization
link where you must authorize the **Content CLI** to be able to access the EMS environment
on your behalf.

With Client Credentials, you need to provide the credentials (Client ID, Client Secret)
and Client Authentication Method configured for your OAuth client. You can create and configure
an OAuth clients in the `Team Settings` section of your EMS account, under `Applications`.
The OAuth client needs to have the following scopes configured: studio.spaces, studio.packages,
studio.widgets, integration.data-models:read, integration.data-pools, transformation-center.kpis,
transformation-center.content:export. After creating an OAuth client, you can assign it different
permissions based on how much power you want to give to the client owner.

##### API Token

You can choose between two different options when asked for an API token.
The first option is to use an API key, which identifies the user that created
Expand Down
1 change: 1 addition & 0 deletions package.json
pavelefros marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"axios": "1.6.2",
"commander": "^6.0.0",
"form-data": "4.0.0",
"openid-client": "^5.6.1",
pavelefros marked this conversation as resolved.
Show resolved Hide resolved
"semver": "^7.3.2",
"valid-url": "^1.0.9",
"winston": "^3.1.0",
Expand Down
33 changes: 31 additions & 2 deletions src/commands/profile.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { QuestionService } from "../services/question.service";
import { Profile } from "../interfaces/profile.interface";
import { ProfileService } from "../services/profile.service";
import { ProfileValidator } from "../validators/profile.validator";
import { logger } from "../util/logger";
import { FatalError, logger } from "../util/logger";

export class ProfileCommand {
private profileService = new ProfileService();
Expand All @@ -11,8 +11,37 @@ export class ProfileCommand {
const profile: Profile = {} as Profile;
profile.name = await QuestionService.ask("Name of the profile: ");
profile.team = await QuestionService.ask("Your team (please provide the full url): ");
profile.apiToken = await QuestionService.ask("Your api token: ");
const type = await QuestionService.ask("Profile type: OAuth Device Code (1), OAuth Client Credentials (2) or Application Key / API Key (3): " );
switch (type) {
case "1":
profile.type = "Device Code";
break;
case "2":
profile.type = "Client Credentials";
profile.clientId = await QuestionService.ask("Your client id: ");
profile.clientSecret = await QuestionService.ask("Your client secret: ");
const authenticationMethod = await QuestionService.ask("Client authentication method: Client Secret Basic (1) or Client Secret Post (2): " );
if (authenticationMethod === "1") {
profile.clientAuthenticationMethod = "client_secret_basic";
}
else if (authenticationMethod === "2") {
profile.clientAuthenticationMethod = "client_secret_post";
}
else {
logger.error(new FatalError("Invalid authentication method"));
}
break;
case "3":
profile.type = "Key";
profile.apiToken = await QuestionService.ask("Your api token: ");
break;
default:
logger.error(new FatalError("Invalid type"));
break;
}
profile.authenticationType = await ProfileValidator.validateProfile(profile);
await this.profileService.authorizeProfile(profile);

this.profileService.storeProfile(profile);
if (setAsDefault) {
await this.makeDefaultProfile(profile.name);
Expand Down
9 changes: 9 additions & 0 deletions src/interfaces/profile.interface.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
export interface Profile {
name: string;
team: string;
type: ProfileType;
apiToken: string;
authenticationType: AuthenticationType;
clientId?: string;
clientSecret?: string;
scopes?: string[];
clientAuthenticationMethod?: ClientAuthenticationMethod;
refreshToken?: string;
expiresAt?: number;
}

export type AuthenticationType = "Bearer" | "AppKey";
export type ProfileType = "Device Code" | "Client Credentials" | "Key";
export type ClientAuthenticationMethod = "client_secret_basic" | "client_secret_post";
pavelefros marked this conversation as resolved.
Show resolved Hide resolved
// tslint:disable-next-line:variable-name
export const AuthenticationType: { [key: string]: AuthenticationType } = {
BEARER: "Bearer",
Expand Down
140 changes: 138 additions & 2 deletions src/services/profile.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,15 @@ import { ProfileValidator } from "../validators/profile.validator";
import * as path from "path";
import * as fs from "fs";
import { FatalError, logger } from "../util/logger";

import { Issuer } from "openid-client";
import axios from "axios";
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 scopes = ["studio.spaces", "studio.packages", "studio.widgets", "integration.data-models:read",
"integration.data-pools", "transformation-center.kpis", "transformation-center.content:export", "action-engine.projects"];

export interface Config {
defaultProfile: string;
Expand All @@ -25,7 +31,9 @@ export class ProfileService {
path.resolve(this.profileContainerPath, this.constructProfileFileName(profileName)),
{ encoding: "utf-8" }
);
resolve(JSON.parse(file));
const profile : Profile = JSON.parse(file);
this.refreshProfile(profile)
.then(() => resolve(profile));
}
} catch (e) {
reject(
Expand Down Expand Up @@ -75,6 +83,7 @@ export class ProfileService {
team: profileVariables.teamUrl,
apiToken: profileVariables.apiToken,
authenticationType: AuthenticationType.BEARER,
type: "Key"
};
profile.authenticationType = await ProfileValidator.validateProfile(profile);
return profile;
Expand Down Expand Up @@ -120,6 +129,105 @@ export class ProfileService {
return fileNames;
}

public async authorizeProfile(profile: Profile) : Promise<void> {
switch (profile.type) {
case "Key":
const url = profile.team.replace(/\/?$/, "/api/cloud/team");
this.tryKeyAuthentication(url, AuthenticationType.BEARER, profile.apiToken).then(() => {
profile.authenticationType = AuthenticationType.BEARER;
}).catch(() => {
this.tryKeyAuthentication(url, AuthenticationType.APPKEY, profile.apiToken).then(() => {
profile.authenticationType = AuthenticationType.APPKEY;
}).catch(() => {
logger.error(new FatalError("The provided team or api key is wrong."));
})
});
break;
case "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: scopes.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."));
}
break;
case "Client Credentials":
try {
const clientCredentialsIssuer = await Issuer.discover(profile.team);
const clientCredentialsOAuthClient = new clientCredentialsIssuer.Client({
client_id: profile.clientId,
client_secret: profile.clientSecret,
token_endpoint_auth_method: profile.clientAuthenticationMethod,
});
const clientCredentialsTokenSet = await clientCredentialsOAuthClient.grant({
grant_type: "client_credentials",
scope: scopes.join(" ")
});
profile.scopes = [...scopes];
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."));
}
break;
default:
logger.error(new FatalError("Unsupported profile type"));
break;
}
}

public async refreshProfile(profile: Profile) : Promise<void> {
if (!this.isProfileExpired(profile, expiryBuffer)) {
return;
}
const issuer = await Issuer.discover(profile.team);
if (profile.type === "Device Code") {
try {
const oauthClient = new issuer.Client({
client_id: "content-cli",
token_endpoint_auth_method: "none",
});
const tokenSet = await oauthClient.refresh(profile.refreshToken);
profile.apiToken = tokenSet.access_token;
profile.expiresAt = tokenSet.expires_at;
profile.refreshToken = tokenSet.refresh_token;
} catch (err) {
logger.error(new FatalError("The profile cannot be refreshed. Please retry or recreate profile."));
}
}
else {
try {
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: profile.scopes.join(" ")
});
profile.apiToken = tokenSet.access_token;
profile.expiresAt = tokenSet.expires_at;
} catch (err) {
logger.error(new FatalError("The profile cannot be refreshed. Please retry or recreate profile."));
}
}

this.storeProfile(profile);
}

private getProfileEnvVariables(): any {
return {
teamUrl: this.getBaseTeamUrl(process.env.TEAM_URL),
Expand All @@ -135,6 +243,34 @@ export class ProfileService {
const url = new URL(teamUrl);
return url.origin;
}

private isProfileExpired(profile: Profile, buffer: number = 0): boolean {
if (profile.type === "Key") {
return false;
}
const now = new Date();
const expirationTime = new Date(profile.expiresAt * 1000 - buffer);

return now > expirationTime;
}

private tryKeyAuthentication(url: string, authType: AuthenticationType, apiToken: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
axios.get(url, {
headers: {
Authorization: `${authType} ${apiToken}`
}
}).then(response => {
if (response.status === 200 && response.data.domain) {
resolve();
} else {
reject();
}
}).catch(() => {
reject();
})
})
}
}

export const profileService = new ProfileService();
62 changes: 16 additions & 46 deletions src/validators/profile.validator.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,23 @@
import { AuthenticationType, Profile } from "../interfaces/profile.interface";
import { Profile } from "../interfaces/profile.interface";
import { FatalError, logger } from "../util/logger";
import validUrl = require("valid-url");
import axios from "axios";

export class ProfileValidator {
public static async validateProfile(profile: Profile): Promise<any> {
return new Promise<any>(async (resolve, reject) => {
if (profile.name == null) {
logger.error(new FatalError("The name can not be empty"));
}
if (profile.team == null) {
logger.error(new FatalError("The team can not be empty"));
}
if (profile.apiToken == null) {
logger.error(new FatalError("The api token can not be empty"));
}
if (!validUrl.isUri(profile.team)) {
logger.error(new FatalError("The provided url is not a valid url."));
}
const url = profile.team.replace(/\/?$/, "/api/cloud/team");

this.tryAuthenticationType(url, AuthenticationType.BEARER, profile.apiToken).then(() => {
resolve(AuthenticationType.BEARER);
}).catch(() => {
this.tryAuthenticationType(url, AuthenticationType.APPKEY, profile.apiToken).then(() => {
resolve(AuthenticationType.APPKEY);
}).catch(() => {
logger.error(new FatalError("The provided team or api key is wrong."));
reject();
})
});
});
}

private static tryAuthenticationType(url: string, authType: AuthenticationType, apiToken: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
axios.get(url, {
headers: {
Authorization: `${authType} ${apiToken}`
}
}).then(response => {
if (response.status === 200 && response.data.domain) {
resolve();
} else {
reject();
}
}).catch(() => {
reject();
})
})
if (profile.name == null) {
logger.error(new FatalError("The name can not be empty"));
}
if (profile.team == null) {
logger.error(new FatalError("The team can not be empty"));
}
if (profile.type === "Key" && profile.apiToken == null) {
logger.error(new FatalError("The api token can not be empty for this profile type"));
}
if (profile.type === "Client Credentials" && (profile.clientId == null || profile.clientSecret == null)) {
logger.error(new FatalError("The client id and secret can not be empty for this profile type"));
}
if (!validUrl.isUri(profile.team)) {
logger.error(new FatalError("The provided url is not a valid url."));
}
}
}
3 changes: 2 additions & 1 deletion tests/utls/context-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ export function setDefaultProfile(): void {
contextService.setContext({
profile: {
name: "test",
type: "Key",
team: "https://myTeam.celonis.cloud/",
apiToken: "YnQ3N2M0M2ItYzQ3OS00YzgyLTg0ODgtOWNkNzhiNzYwOTU2OlFkNnBpVCs0M0JBYm1ZWGlCZ2hPd245aldwWTNubFQyYVFOTFBUeHEwdUxM",
authenticationType: "Bearer"
}
});
}
}
Loading
Loading