Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
7 changes: 4 additions & 3 deletions x-pack/plugins/cloud/.storybook/decorator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ const services: CloudServices = {
chat: {
enabled: true,
chatURL: 'https://elasticcloud-production-chat-us-east-1.s3.amazonaws.com/drift-iframe.html',
userID: '53877975',
userID: 'user-id',
userEmail: '[email protected]',
identityJWT:
'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI1Mzg3Nzk3NSIsImV4cCI6MTY0MjUxNDc0Mn0.CcAZbD8R865UmoHGi27wKn0aH1bzkZXhX449yyDH2Vk',
// this doesn't affect chat appearance,
// but a user identity in Drift only
identityJWT: 'identity-jwt',
},
};

Expand Down
4 changes: 1 addition & 3 deletions x-pack/plugins/cloud/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,4 @@
* 2.0.
*/

import { defaultConfig } from '@kbn/storybook';

module.exports = defaultConfig;
export { defaultConfig } from '@kbn/storybook';
2 changes: 1 addition & 1 deletion x-pack/plugins/cloud/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

export const ELASTIC_SUPPORT_LINK = 'https://cloud.elastic.co/support';
export const GET_CHAT_TOKEN_ROUTE_PATH = '/internal/engagement/chat_token';
export const GET_CHAT_USER_DATA_ROUTE_PATH = '/internal/cloud/chat_user';

/**
* This is the page for managing your snapshots on Cloud.
Expand Down
4 changes: 3 additions & 1 deletion x-pack/plugins/cloud/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* 2.0.
*/

export interface GetChatTokenResponseBody {
export interface GetChatUserDataResponseBody {
token: string;
email: string;
id: string;
}
3 changes: 2 additions & 1 deletion x-pack/plugins/cloud/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import { PluginInitializerContext } from '../../../../src/core/public';
import { CloudPlugin } from './plugin';

export type { CloudSetup, CloudConfigType, CloudStart } from './plugin';

export function plugin(initializerContext: PluginInitializerContext) {
return new CloudPlugin(initializerContext);
}

export { Chat, LazyChat } from './components';
export { Chat } from './components';
2 changes: 1 addition & 1 deletion x-pack/plugins/cloud/public/mocks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const getContextProvider: () => React.FC =
<ServicesProvider {...config}>{children}</ServicesProvider>;

const createStartMock = (): jest.Mocked<CloudStart> => ({
ContextProvider: jest.fn(getContextProvider()),
CloudContextProvider: jest.fn(getContextProvider()),
});

export const cloudMock = {
Expand Down
83 changes: 14 additions & 69 deletions x-pack/plugins/cloud/public/plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ import { getIsCloudEnabled } from '../common/is_cloud_enabled';
import {
ELASTIC_SUPPORT_LINK,
CLOUD_SNAPSHOTS_PATH,
GET_CHAT_TOKEN_ROUTE_PATH,
GET_CHAT_USER_DATA_ROUTE_PATH,
} from '../common/constants';
import type { GetChatTokenResponseBody } from '../common/types';
import type { GetChatUserDataResponseBody } from '../common/types';
import { HomePublicPluginSetup } from '../../../../src/plugins/home/public';
import { createUserMenuLinks } from './user_menu_links';
import { getFullCloudUrl } from './utils';
Expand Down Expand Up @@ -62,9 +62,9 @@ interface CloudStartDependencies {

export interface CloudStart {
/**
* A React component that provides a pre-wired `React.Context` which connects components to Engagement services.
* A React component that provides a pre-wired `React.Context` which connects components to Cloud services.
*/
ContextProvider: FC<{}>;
CloudContextProvider: FC<{}>;
}

export interface CloudSetup {
Expand Down Expand Up @@ -178,7 +178,7 @@ export class CloudPlugin implements Plugin<CloudSetup> {
.catch(() => setLinks(true));

return {
ContextProvider: ({ children }) => (
CloudContextProvider: ({ children }) => (
<ServicesProvider chat={this.chatService}>{children}</ServicesProvider>
),
};
Expand Down Expand Up @@ -303,39 +303,21 @@ export class CloudPlugin implements Plugin<CloudSetup> {
}

private async setupChat({ http, security }: SetupChatDeps) {
if (!security) {
return;
}

const { enabled, chatURL } = this.config.chat;

if (!enabled || !chatURL) {
if (!security || !enabled || !chatURL) {
return;
}

const user = await loadChatUser({ getCurrentUser: security.authc.getCurrentUser });

if (!user) {
return;
}
const chatUserData = await http.get<GetChatUserDataResponseBody>(GET_CHAT_USER_DATA_ROUTE_PATH);

try {
const response = await http.get<GetChatTokenResponseBody>(GET_CHAT_TOKEN_ROUTE_PATH, {
query: {
userId: user.userID,
},
});

this.chatService = {
...user,
enabled,
chatURL,
identityJWT: response.token,
};
} catch (error) {
// TODO: add logger
return;
}
this.chatService = {
enabled,
chatURL,
userEmail: chatUserData.email,
userID: chatUserData.id,
identityJWT: chatUserData.token,
};
}
}

Expand Down Expand Up @@ -368,40 +350,3 @@ export const loadFullStoryUserId = async ({
return undefined;
}
};

/** @internal exported for testing */
export const loadChatUser = async ({
getCurrentUser,
}: {
getCurrentUser: () => Promise<AuthenticatedUser>;
}) => {
try {
const currentUser = await getCurrentUser().catch(() => undefined);

if (!currentUser) {
return;
}

const { email: userEmail, username: userID } = currentUser;

// Log very defensively here so we can debug this easily if it breaks
if (!userID || !userEmail) {
// eslint-disable-next-line no-console
console.debug(
`[cloud.chat] userID or userEmail not specified. User metadata: ${JSON.stringify(
currentUser.metadata
)}`
);
return;
}

return {
userID,
userEmail,
};
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[cloud.chat] Error loading the current user: ${e.toString()}`, e);
return undefined;
}
};
2 changes: 1 addition & 1 deletion x-pack/plugins/cloud/public/services/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const ServicesProvider: FC<CloudServices> = ({ children, ...services }) =
);

/**
* React hook for accessing the pre-wired `EngagementServices`.
* React hook for accessing the pre-wired `CloudServices`.
*/
export function useServices() {
return useContext(ServicesContext);
Expand Down
7 changes: 5 additions & 2 deletions x-pack/plugins/cloud/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'src/core/server';
import type { SecurityPluginSetup } from '../../security/server';
import { CloudConfigType } from './config';
import { registerCloudUsageCollector } from './collectors';
import { getIsCloudEnabled } from '../common/is_cloud_enabled';
Expand All @@ -16,6 +17,7 @@ import { registerChatRoute } from './routes/chat';

interface PluginsSetup {
usageCollection?: UsageCollectionSetup;
security?: SecurityPluginSetup;
}

export interface CloudSetup {
Expand All @@ -37,7 +39,7 @@ export class CloudPlugin implements Plugin<CloudSetup> {
this.config = this.context.config.get<CloudConfigType>();
}

public setup(core: CoreSetup, { usageCollection }: PluginsSetup) {
public setup(core: CoreSetup, { usageCollection, security }: PluginsSetup) {
this.logger.debug('Setting up Cloud plugin');
const isCloudEnabled = getIsCloudEnabled(this.config.id);
registerCloudUsageCollector(usageCollection, { isCloudEnabled });
Expand All @@ -51,8 +53,9 @@ export class CloudPlugin implements Plugin<CloudSetup> {

if (this.config.chat.enabled && this.config.chatIdentitySecret) {
registerChatRoute({
httpResources: core.http.resources,
router: core.http.createRouter(),
chatIdentitySecret: this.config.chatIdentitySecret,
security,
});
}

Expand Down
57 changes: 35 additions & 22 deletions x-pack/plugins/cloud/server/routes/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,38 +5,51 @@
* 2.0.
*/

import { schema } from '@kbn/config-schema';
import { HttpResources } from '../../../../../src/core/server';
import { GET_CHAT_TOKEN_ROUTE_PATH } from '../../common/constants';
import type { GetChatTokenResponseBody } from '../../common/types';
import { IRouter } from '../../../../../src/core/server';
import type { SecurityPluginSetup } from '../../../security/server';
import { GET_CHAT_USER_DATA_ROUTE_PATH } from '../../common/constants';
import type { GetChatUserDataResponseBody } from '../../common/types';
import { generateSignedJwt } from '../util/generate_jwt';

export const registerChatRoute = ({
httpResources,
router,
chatIdentitySecret,
security,
}: {
httpResources: HttpResources;
router: IRouter;
chatIdentitySecret: string;
security?: SecurityPluginSetup;
}) => {
httpResources.register(
router.get(
{
// Use the build number in the URL path to leverage max-age caching on production builds
path: GET_CHAT_TOKEN_ROUTE_PATH,
validate: {
query: schema.object({
userId: schema.string(),
}),
},
options: {
authRequired: false,
},
path: GET_CHAT_USER_DATA_ROUTE_PATH,
validate: {},
},
async (context, request, response) => {
const {
query: { userId },
} = request;
const token = generateSignedJwt(userId, chatIdentitySecret);
const body: GetChatTokenResponseBody = { token };
if (!security) {
return response.customError({
statusCode: 500,
});
}

const user = await security.authc.getCurrentUser(request);
let { email: userEmail, username: userID } = user || {};
// TODO: this is for testing purpose, cz a user in local env
// doesn't have an email
userEmail = userEmail || `test+${userID}@elasticsearch.com`;

if (!userEmail || !userID) {
return response.badRequest({
body: 'User has no email or username',
});
}

const token = generateSignedJwt(userID, chatIdentitySecret);
const body: GetChatUserDataResponseBody = {
token,
email: userEmail,
id: userID,
};
return response.ok({ body });
}
);
Expand Down
25 changes: 25 additions & 0 deletions x-pack/plugins/cloud/server/util/generate_jwt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { generateSignedJwt } from './generate_jwt';

describe('generateJWT', () => {
beforeAll(() => {
jest.useFakeTimers('modern').setSystemTime(new Date('2022-01-01').getTime());
});

it('should generate a JWT', () => {
const userId = 'user-id';
const secret = 'secret';
const token = generateSignedJwt(userId, secret);
expect(token).toBeDefined();
expect(token.split('.').length).toBe(3);
expect(token).toEqual(
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLWlkIiwiaWF0IjoxNjQwOTk1MjAwLCJleHAiOjE2NDA5OTU1MDB9.QDptR3Ygzjs5bcOzm57xqETqsX05YtydbiyfXrh_4h8'
);
});
});
60 changes: 13 additions & 47 deletions x-pack/plugins/cloud/server/util/generate_jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,55 +5,21 @@
* 2.0.
*/

import crypto from 'node:crypto';

const toBase64 = (object: Record<string, unknown>): string => {
const str = JSON.stringify(object);
return Buffer.from(str).toString('base64');
};

const replaceSpecialChars = (b64string: string): string => {
// create a regex to match any of the characters =,+ or / and replace them with their // substitutes
return b64string.replace(/[=+/]/g, (charToBeReplaced) => {
switch (charToBeReplaced) {
case '=':
return '';
case '+':
return '-';
case '/':
return '_';
default:
return charToBeReplaced;
}
});
};

const generateJwtB64 = (object: Record<string, unknown>): string => {
const b64 = toBase64(object);
return replaceSpecialChars(b64);
};

const createSignature = (header: string, payload: string, secret: string): string => {
const signature = crypto.createHmac('sha256', secret);
signature.update([header, payload].join('.'));
const signatureStr = signature.digest('base64');
return replaceSpecialChars(signatureStr);
};
// eslint-disable-next-line import/no-extraneous-dependencies
import jwt from 'jsonwebtoken';

export const generateSignedJwt = (userId: string, secret: string): string => {
const header = generateJwtB64({
alg: 'HS256',
typ: 'JWT',
});

const EXP_MS = 5 * 60 * 1000;
const expirationTime = Date.now() + EXP_MS;
const payload = generateJwtB64({
const options = {
header: {
alg: 'HS256',
typ: 'JWT',
},
expiresIn: 5 * 60, // 5m
};

const payload = {
sub: userId,
exp: expirationTime,
});

const signature = createSignature(header, payload, secret);
};

return [header, payload, signature].join('.');
return jwt.sign(payload, secret, options);
};
Loading