Skip to content

Commit

Permalink
feat(cli): add token authentication (#401)
Browse files Browse the repository at this point in the history
* feat(cli): add token authentication

MVP, need:
 - Refacto w/ login
 - Permission validation

* handle anonymous in analytics

cause an API key cannot be associated to a user with confidence

* add doc page

* Pr review

Co-authored-by: Yassine <[email protected]>
  • Loading branch information
louis-bompart and y-lakhdar authored Aug 3, 2021
1 parent 3d52153 commit 967877c
Show file tree
Hide file tree
Showing 5 changed files with 292 additions and 14 deletions.
138 changes: 138 additions & 0 deletions packages/cli/src/commands/auth/token.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
jest.mock('../../lib/oauth/oauth');
jest.mock('../../lib/config/config');
jest.mock('../../hooks/analytics/analytics');
jest.mock('../../hooks/prerun/prerun');
jest.mock('../../lib/platform/authenticatedClient');
jest.mock('@coveord/platform-client');
import {test} from '@oclif/test';
import {mocked} from 'ts-jest/utils';
import {Config} from '../../lib/config/config';
import {AuthenticatedClient} from '../../lib/platform/authenticatedClient';
const mockedConfig = mocked(Config, true);
const mockedAuthenticatedClient = mocked(AuthenticatedClient);

describe('auth:token', () => {
const mockConfigSet = jest.fn();

const mockConfigGet = jest.fn().mockReturnValue(
Promise.resolve({
region: 'us-east-1',
organization: 'foo',
environment: 'prod',
})
);

const mockListOrgs = jest
.fn()
.mockReturnValue(Promise.resolve([{id: 'foo'}]));

const mockGetHasAccessToOrg = jest
.fn()
.mockReturnValue(Promise.resolve(true));

beforeEach(() => {
mockedAuthenticatedClient.mockImplementation(
() =>
({
getAllOrgsUserHasAccessTo: mockListOrgs,
getUserHasAccessToOrg: mockGetHasAccessToOrg,
} as unknown as AuthenticatedClient)
);

mockedConfig.mockImplementation(
() =>
({
get: mockConfigGet,
set: mockConfigSet,
} as unknown as Config)
);
});

afterEach(() => {
mockConfigSet.mockClear();
});

test
.command(['auth:token', '-e', 'foo'])
.catch(/Expected --environment=foo/)
.it('reject invalid environment', async () => {});

test
.command(['auth:token', '-r', 'foo'])
.catch(/Expected --region=foo/)
.it('reject invalid region', async () => {});

['dev', 'qa', 'prod', 'hipaa'].forEach((environment) => {
test
.stdout()
.command(['auth:token', '-e', environment, '-t', 'someToken'])
.it(`writes the -e=${environment} flag to the configuration`, () => {
expect(mockConfigSet).toHaveBeenCalledWith('environment', environment);
});
});

[
'us-east-1',
'eu-west-1',
'eu-west-3',
'ap-southeast-2',
'us-west-2',
].forEach((region) => {
test
.stdout()
.command(['auth:token', '-r', region, '-t', 'someToken'])
.it(`writes the -r=${region} flag and configuration`, () => {
expect(mockConfigSet).toHaveBeenCalledWith('region', region);
});
});

describe('writes the token and set anonymous to true in the configuration ', () => {
test
.stdout()
.command(['auth:token', '-t', 'this-is-the-token'])
.it('save token from oauth service', () => {
expect(mockConfigSet).toHaveBeenCalledWith(
'accessToken',
'this-is-the-token'
);
expect(mockConfigSet).toHaveBeenCalledWith('anonymous', true);
});
});

test
.stdout()
.command(['auth:token'])
.exit(2)
.it('fails when the token flag is not set');

test
.stdout()
.do(() => {
mockGetHasAccessToOrg.mockReturnValueOnce(Promise.resolve(true));
})
.command(['auth:token', '-t', 'some-token'])
.it(
'succeed when the organization and the token flags are valid',
(ctx) => {
expect(ctx.stdout).toContain('Success');
}
);

test
.do(() => {
mockListOrgs.mockReturnValueOnce(
Promise.resolve([{id: 'the_first_org_available'}])
);
})
.stdout()
.command(['auth:token', '-t', 'some-token'])
.it(
'find the org associated with the token and saves it in the config',
() => {
expect(mockConfigSet).toHaveBeenCalledWith(
'organization',
'the_first_org_available'
);
}
);
});
110 changes: 110 additions & 0 deletions packages/cli/src/commands/auth/token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import {Command, flags} from '@oclif/command';
import {Config} from '../../lib/config/config';
import {AuthenticatedClient} from '../../lib/platform/authenticatedClient';
import {
PlatformEnvironment,
PlatformRegion,
} from '../../lib/platform/environment';
import {
buildAnalyticsFailureHook,
buildAnalyticsSuccessHook,
} from '../../hooks/analytics/analytics';

export default class Token extends Command {
private configuration!: Config;
public static description =
'Log in to the Coveo Platform using the OAuth2 flow.';

public static examples = ['$ coveo auth:token'];

public static flags = {
region: flags.string({
char: 'r',
options: [
'us-east-1',
'eu-west-1',
'eu-west-3',
'ap-southeast-2',
'us-west-2',
],
default: 'us-east-1',
description:
'The Coveo Platform region to log in to. See <https://docs.coveo.com/en/2976>.',
}),
environment: flags.string({
char: 'e',
options: ['dev', 'qa', 'prod', 'hipaa'],
default: 'prod',
description: 'The Coveo Platform environment to log in to.',
}),
token: flags.string({
char: 't',
description:
'The API-Key that shall be used to authenticate you to the organization. See <https://github.com/coveo/cli/wiki/Using-the-CLI-using-an-API-Key>.',
required: true,
helpValue: 'xxx-api-key',
}),
};

public async run() {
this.configuration = new Config(this.config.configDir, this.error);
this.saveToken();
this.saveRegionAndEnvironment();
await this.fetchAndSaveOrgId();
await this.feedbackOnSuccessfulLogin();
this.config.runHook('analytics', buildAnalyticsSuccessHook(this, flags));
}

public async catch(err?: Error) {
const flags = this.flags;
await this.config.runHook(
'analytics',
buildAnalyticsFailureHook(this, flags, err)
);
throw err;
}

private async feedbackOnSuccessfulLogin() {
const cfg = this.configuration.get();
this.log(`
Successfully logged in!
Close your browser to continue.
You are currently logged in:
Organization: ${cfg.organization}
Region: ${cfg.region}
Environment: ${cfg.environment}
Run auth:login --help to see the available options to log in to a different organization, region, or environment.
`);
}

private saveToken() {
const flags = this.flags;
this.configuration.set('accessToken', flags.token);
this.configuration.set('anonymous', true);
}

private saveRegionAndEnvironment() {
const flags = this.flags;
const cfg = this.configuration;
cfg.set('environment', flags.environment as PlatformEnvironment);
cfg.set('region', flags.region as PlatformRegion);
}

private async fetchAndSaveOrgId() {
this.configuration.set(
'organization',
await this.pickFirstAvailableOrganization()
);
}

private get flags() {
const {flags} = this.parse(Token);
return flags;
}

private async pickFirstAvailableOrganization() {
const orgs = await new AuthenticatedClient().getAllOrgsUserHasAccessTo();
return orgs[0]?.id;
}
}
35 changes: 29 additions & 6 deletions packages/cli/src/hooks/analytics/analytics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,19 +42,22 @@ describe('analytics hook', () => {
);
};

const mockedUserGet = jest.fn();
const doMockPlatformClient = () => {
mockedUserGet.mockReturnValue(
Promise.resolve({
username: '[email protected]',
displayName: 'bob',
})
);
mockedPlatformClient.mockImplementation(
() =>
({
initialize: () => Promise.resolve(),
user: {
get: () =>
Promise.resolve({
username: '[email protected]',
displayName: 'bob',
}),
get: mockedUserGet,
},
} as PlatformClient)
} as unknown as PlatformClient)
);
};

Expand Down Expand Up @@ -235,4 +238,24 @@ describe('analytics hook', () => {
);
await expect(hook(getAnalyticsHook({}))).resolves.not.toThrow();
});

it('should not fetch userinfo if anonymous is set to true in the config', async () => {
mockedConfig.mockImplementation(
() =>
({
get: () =>
({
environment: 'dev',
organization: 'foo',
region: 'us-east-1',
analyticsEnabled: true,
anonymous: true,
} as Configuration),
} as Config)
);

await hook(getAnalyticsHook({}));

expect(mockedUserGet).not.toBeCalled();
});
});
21 changes: 13 additions & 8 deletions packages/cli/src/hooks/analytics/analytics.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Command from '@oclif/command';
import {IConfig} from '@oclif/config';
import {CoveoAnalyticsClient} from 'coveo.analytics';
import type {CustomEventRequest} from 'coveo.analytics/dist/definitions/events';
import {WebStorage} from 'coveo.analytics/dist/definitions/storage';
import {Config} from '../../lib/config/config';
import {
Expand Down Expand Up @@ -45,8 +46,7 @@ const hook = async function (opts: AnalyticsHook) {
// }

const analyticsClient = configureAnalyticsClient(authenticatedClient);

await analyticsClient.sendCustomEvent({
const analyticsData: CustomEventRequest = {
eventType,
eventValue,
customData: {
Expand All @@ -62,11 +62,14 @@ const hook = async function (opts: AnalyticsHook) {
originLevel1: eventType,
originLevel2: eventValue,
userAgent: opts.config.userAgent,
userDisplayName: userInfo.displayName,
anonymous: false,
username: userInfo.username,
language: 'en',
});
};
if (userInfo) {
analyticsData.userDisplayName = userInfo.displayName;
analyticsData.username = userInfo.username;
}
await analyticsClient.sendCustomEvent(analyticsData);
};

const identifier = (opts: AnalyticsHook) => ({
Expand All @@ -86,9 +89,11 @@ const platformInfoIdentifier = async () => {
const authenticatedClient = new AuthenticatedClient();
const platformClient = await authenticatedClient.getClient();
await platformClient.initialize();

const userInfo = await platformClient.user.get();
const config = await authenticatedClient.cfg.get();
const config = authenticatedClient.cfg.get();
let userInfo;
if (!config.anonymous) {
userInfo = await platformClient.user.get();
}
return {
userInfo,
authenticatedClient,
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/lib/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface Configuration {
[k: string]: unknown;
analyticsEnabled: boolean | undefined;
accessToken: string | undefined;
anonymous?: boolean | undefined;
}

export const DefaultConfig: Configuration = {
Expand All @@ -22,6 +23,7 @@ export const DefaultConfig: Configuration = {
organization: '',
analyticsEnabled: undefined,
accessToken: undefined,
anonymous: undefined,
};

export class Config {
Expand Down

0 comments on commit 967877c

Please sign in to comment.