diff --git a/package.json b/package.json index 530e5bd58..bbe9e9322 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@oclif/plugin-update": "^1.3.10", "@types/command-exists": "^1.2.0", "@types/fs-extra": "^9.0.1", - "@types/js-yaml": "^3.12.5", + "@types/inquirer": "^7.3.1", "@types/node-notifier": "^8.0.0", "@types/request": "^2.48.5", "@types/websocket": "^1.0.1", @@ -36,6 +36,7 @@ "execa": "^4.0.3", "fancy-test": "^1.4.9", "fs-extra": "^9.0.1", + "inquirer": "^7.3.3", "js-yaml": "^3.14.0", "listr": "^0.14.3", "listr-verbose-renderer": "^0.6.0", @@ -43,6 +44,7 @@ "mkdirp": "^1.0.4", "node-forge": "^0.10.0", "node-notifier": "^8.0.0", + "querystring": "^0.2.0", "stream-buffers": "^3.0.2", "tslib": "^1" }, @@ -53,6 +55,7 @@ "@oclif/tslint": "^3", "@types/chai": "^4", "@types/jest": "26.0.14", + "@types/js-yaml": "^3.12.5", "@types/listr": "^0.14.2", "@types/node": "^12", "@types/node-forge": "^0.9.5", @@ -95,11 +98,14 @@ "@oclif/plugin-update" ], "topics": { + "auth": { + "description": "Manage Eclipse Che server login sessions" + }, "server": { - "description": "control Eclipse Che server" + "description": "Control Eclipse Che server" }, "workspace": { - "description": "control Che workspaces" + "description": "Control Che workspaces" } }, "update": { diff --git a/src/api/che-api-client.ts b/src/api/che-api-client.ts index 907ec703a..cae2714b6 100644 --- a/src/api/che-api-client.ts +++ b/src/api/che-api-client.ts @@ -44,18 +44,16 @@ export class CheApiClient { return instance } - private static normalizeCheApiEndpointUrl(url: string | undefined) { - if (url) { - if (!url.includes('://')) { - url = 'https://' + url - } - const u = new URL(url) - url = 'https://' + u.host + u.pathname - if (url.endsWith('/')) { - url = url.slice(0, -1) - } - return url + public static normalizeCheApiEndpointUrl(url: string) { + if (!url.includes('://')) { + url = 'https://' + url } + const u = new URL(url) + url = 'https://' + u.host + u.pathname + if (url.endsWith('/')) { + url = url.slice(0, -1) + } + return url } /** @@ -101,9 +99,7 @@ export class CheApiClient { } catch (error) { throw this.getCheApiError(error, endpoint) } - if (!response || response.status !== 200 || !response.data || !response.data.status) { - throw new Error('E_BAD_RESP_CHE_API') - } + this.checkResponse(response, endpoint) return response.data.status } @@ -236,9 +232,7 @@ export class CheApiClient { } } - if (!response || response.status !== 200 || !response.data) { - throw new Error('E_BAD_RESP_CHE_API') - } + this.checkResponse(response, endpoint) } async stopWorkspace(workspaceId: string, accessToken?: string): Promise { @@ -298,9 +292,31 @@ export class CheApiClient { } } + /** + * Returns Keycloak settings or undefined for single user mode. + */ + async getKeycloakSettings(responseTimeoutMs = this.defaultCheResponseTimeoutMs): Promise { + const endpoint = `${this.cheApiEndpoint}/keycloak/settings` + let response + try { + response = await this.axios.get(endpoint, { timeout: responseTimeoutMs }) + } catch (error) { + if (error.response && error.response.status === 404) { + return + } + throw this.getCheApiError(error, endpoint) + } + this.checkResponse(response, endpoint) + if (!response.data['che.keycloak.token.endpoint']) { + // The response is not keycloak response, but a default fallback + throw new Error('E_BAD_CHE_API_URL') + } + return response.data + } + async isAuthenticationEnabled(responseTimeoutMs = this.defaultCheResponseTimeoutMs): Promise { const endpoint = `${this.cheApiEndpoint}/keycloak/settings` - let response = null + let response try { response = await this.axios.get(endpoint, { timeout: responseTimeoutMs }) } catch (error) { @@ -310,13 +326,21 @@ export class CheApiClient { throw this.getCheApiError(error, endpoint) } } - if (!response || response.status !== 200 || !response.data) { - throw new Error('E_BAD_RESP_CHE_API') + this.checkResponse(response, endpoint) + if (!response.data['che.keycloak.token.endpoint']) { + // The response is not keycloak response, but a default fallback + return false } return true } - getCheApiError(error: any, endpoint: string): Error { + private checkResponse(response: any, endpoint?: string): void { + if (!response || response.status !== 200 || !response.data) { + throw new Error(`E_BAD_RESP_CHE_API - Response code: ${response.status}` + endpoint ? `, endpoint: ${endpoint}` : '') + } + } + + private getCheApiError(error: any, endpoint: string): Error { if (error.response) { const status = error.response.status if (status === 403) { @@ -325,6 +349,8 @@ export class CheApiClient { return new Error(`E_CHE_API_UNAUTHORIZED - Endpoint: ${endpoint} - Message: ${JSON.stringify(error.response.data)}`) } else if (status === 404) { return new Error(`E_CHE_API_NOTFOUND - Endpoint: ${endpoint} - Message: ${JSON.stringify(error.response.data)}`) + } else if (status === 503) { + return new Error(`E_CHE_API_UNAVAIL - Endpoint: ${endpoint} returned 503 code`) } else { // The request was made and the server responded with a status code // that falls out of the range of 2xx diff --git a/src/api/che-login-manager.ts b/src/api/che-login-manager.ts new file mode 100644 index 000000000..c9f91855d --- /dev/null +++ b/src/api/che-login-manager.ts @@ -0,0 +1,544 @@ +/********************************************************************* + * Copyright (c) 2020 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + **********************************************************************/ + +import axios, { AxiosInstance } from 'axios' +import * as fs from 'fs' +import * as https from 'https' +import * as path from 'path' +import * as querystring from 'querystring' + +import { ACCESS_TOKEN_KEY } from '../common-flags' + +import { CheApiClient } from './che-api-client' + +// Represents login information to use for requests +// Notice: accessToken is undefined for single user mode +export interface LoginData { + cheApiEndpoint: string + accessToken: string | undefined +} + +// Credentials file format +export interface CheServerLoginConfig { + // Defines file format version + version?: string + // Define current login session. Empty if none. + lastLoginUrl?: string + lastUserName?: string + // Registered logins + logins?: Logins +} + +// API URL -> logins into server +export type Logins = { [key: string]: ServerLogins } +// username -> login data +export type ServerLogins = { [key: string]: RefreshTokenLoginRecord } +export type LoginRecord = RefreshTokenLoginRecord | PasswordLoginRecord | OcUserTokenLoginRecord + +export interface RefreshTokenLoginRecord { + refreshToken: string + // Expiration datetime (in seconds) for local timezone + expires: number +} + +export interface OcUserTokenLoginRecord { + subjectToken: string + subjectIssuer: string +} + +export interface PasswordLoginRecord { + username: string + password: string +} + +export function isRefreshTokenLoginData(loginData: LoginRecord): loginData is RefreshTokenLoginRecord { + return !!(loginData as RefreshTokenLoginRecord).refreshToken +} + +export function isOcUserTokenLoginData(loginData: LoginRecord): loginData is OcUserTokenLoginRecord { + return !!(loginData as OcUserTokenLoginRecord).subjectToken +} + +export function isPasswordLoginData(loginData: LoginRecord): loginData is PasswordLoginRecord { + return !!(loginData as PasswordLoginRecord).password +} + +// Response structure from /api/keycloak/settings +interface CheKeycloakSettings { + 'che.keycloak.logout.endpoint': string + 'che.keycloak.jwks.endpoint': string + 'che.keycloak.token.endpoint': string + 'che.keycloak.userinfo.endpoint': string + 'che.keycloak.client_id': string + 'che.keycloak.username_claim': string + 'che.keycloak.js_adapter_url': string + 'che.keycloak.use_nonce': string + + 'che.keycloak.profile.endpoint'?: string + 'che.keycloak.auth_server_url'?: string + 'che.keycloak.password.endpoint'?: string + 'che.keycloak.realm'?: string + + 'che.keycloak.oidc_provider'?: string + 'che.keycloak.github.endpoint'?: string +} + +// Response structure from Keycloak get access token endpoint +interface KeycloakAuthTokenResponse { + access_token: string + expires_in: number | string + refresh_token: string + refresh_expires_in?: number | string + token_type: string + scope?: string +} + +const REQUEST_TIMEOUT_MS = 10000 +const LOGIN_DATA_FILE_NAME = 'che-login-config.json' + +let loginContext: CheServerLoginManager | undefined +/** + * Che server login sessions manager. Singleton. + * Uses refresh tokens for authentication. + * Usually, just using of getLoginData function is suitable. + */ +export class CheServerLoginManager { + private loginData: CheServerLoginConfig + private apiUrl: string + private username: string + + private readonly dataFilePath: string + private readonly axios: AxiosInstance + + private constructor(dataFilePath: string) { + this.dataFilePath = dataFilePath + + this.loginData = {} + this.readLoginData() + this.apiUrl = this.loginData.lastLoginUrl || '' + this.username = this.loginData.lastUserName || '' + + // Remove outdated login records + this.removeExpiredLogins() + + // Make axios ignore untrusted certificate error for self-signed certificate case. + const httpsAgent = new https.Agent({ rejectUnauthorized: false }) + this.axios = axios.create({ + httpsAgent + }) + } + + /** + * Returns Che server login sessions manager. + * @param configDirPath path to chectl config folder + */ + static async getInstance(configDirPath: string): Promise { + if (!fs.existsSync(configDirPath)) { + fs.mkdirSync(configDirPath) + } + const dataFilePath = path.join(configDirPath, LOGIN_DATA_FILE_NAME) + if (loginContext && loginContext.dataFilePath === dataFilePath) { + return loginContext + } + + loginContext = new CheServerLoginManager(dataFilePath) + return loginContext + } + + /** + * Checks whether login credentials exists for given server and user. + * @param apiUrl API URL of the Che server + * @param username username + */ + public hasLoginFor(apiUrl: string, username?: string): boolean { + apiUrl = CheApiClient.normalizeCheApiEndpointUrl(apiUrl) + if (username) { + return !!this.getLoginRecord(apiUrl, username) + } else { + return !!this.loginData.logins![apiUrl] + } + } + + public getCurrentLoginInfo(): { cheApiEndpoint: string, username: string } { + return { cheApiEndpoint: this.apiUrl, username: this.username } + } + + public getCurrentServerApiUrl(): string { + return this.apiUrl + } + + public getAllLogins(): Map { + this.removeExpiredLogins() + + const allLogins = new Map() + for (const [apiUrl, serverLogins] of Object.entries(this.loginData.logins!)) { + allLogins.set(apiUrl, Array.from(Object.keys(serverLogins))) + } + return allLogins + } + + /** + * Logins user in specified instance of Che Server. + * Makes this login data default context. + * If a context with the same data already exists it will be replaced. + * If provided data is invalid, exception will be thrown. + * Returns username of the login. + * @param apiUrl Che server API URL + * @param loginRecord user credentials + */ + public async setLoginContext(apiUrl: string, loginRecord: LoginRecord): Promise { + apiUrl = CheApiClient.normalizeCheApiEndpointUrl(apiUrl) + const cheKeycloakSettings = await this.retrieveKeycloakSettings(apiUrl) + + // Check whether provided login credentials valid and get refresh token. + const keycloakAuthData = await this.keycloakAuth(apiUrl, loginRecord, cheKeycloakSettings) + const now = (Date.now() / 1000) + let refreshTokenExpiresIn: string | number = keycloakAuthData.refresh_expires_in ? keycloakAuthData.refresh_expires_in : keycloakAuthData.expires_in + if (typeof refreshTokenExpiresIn === 'string') { + refreshTokenExpiresIn = parseFloat(refreshTokenExpiresIn) + } + const refreshTokenLoginRecord: RefreshTokenLoginRecord = { + refreshToken: keycloakAuthData.refresh_token, + expires: now + refreshTokenExpiresIn + } + + const username = isPasswordLoginData(loginRecord) ? loginRecord.username : + await this.getCurrentUserName(cheKeycloakSettings, keycloakAuthData.access_token) + + // Delete outdated logins as config file will be rewritten + this.removeExpiredLogins() + + // Credentials are valid, make them current + this.setCurrentLoginContext(apiUrl, username, refreshTokenLoginRecord) + // Save changes permanently + this.saveLoginData() + return username + } + + /** + * Changes current login. + */ + public async switchLoginContext(apiUrl: string, username: string): Promise { + // Get rid of outdated credentials before trying to switch current login + this.removeExpiredLogins() + + apiUrl = CheApiClient.normalizeCheApiEndpointUrl(apiUrl) + const loginRecord = this.getLoginRecord(apiUrl, username) + if (!loginRecord) { + throw new Error(`User "${username}" is not logged in on "${apiUrl}" server`) + } + + // Ensure the server is reachable and credentials are still valid + const keycloakAuthData = await this.keycloakAuth(apiUrl, loginRecord) + // Update refresh token + loginRecord.refreshToken = keycloakAuthData.refresh_token + + this.setCurrentLoginContext(apiUrl, username, loginRecord) + this.saveLoginData() + } + + /** + * Logouts user from specified Che server. + * If no parameters given current login session will be deleted. + * @param apiUrl Che server API URL + * @param username username on the given server + */ + public deleteLoginContext(apiUrl?: string, username?: string): void { + if (!this.loginData.logins) { + return + } + + if (!apiUrl) { + if (!this.apiUrl) { + // Not logged in + return + } + // Delete current login context + return this.deleteLoginContext(this.apiUrl, this.username) + } + + apiUrl = CheApiClient.normalizeCheApiEndpointUrl(apiUrl) + + if (!username) { + // Delete all logins on the server + delete this.loginData.logins![apiUrl] + } else { + // Delete specific login record if any + const serverLogins = this.loginData.logins[apiUrl] + if (!serverLogins) { + // No logins for specified server + return + } + delete serverLogins[username] + if (Object.keys(serverLogins).length < 1) { + // Delete server without logins + delete this.loginData.logins[apiUrl] + } + } + + if (apiUrl === this.apiUrl) { + // Current login info should be deleted + this.loginData.lastLoginUrl = this.apiUrl = '' + this.loginData.lastUserName = this.username = '' + } + this.removeExpiredLogins() + this.saveLoginData() + } + + private readLoginData(): void { + if (fs.existsSync(this.dataFilePath)) { + this.loginData = JSON.parse(fs.readFileSync(this.dataFilePath).toString()) as CheServerLoginConfig + } else { + this.loginData = {} + } + + if (!this.loginData.logins) { + this.loginData.logins = {} + } + + if (!this.loginData.version) { + // So far there is only one existing file format + this.loginData.version = 'v1' + } + } + + private saveLoginData(): void { + this.loginData.lastLoginUrl = this.apiUrl + this.loginData.lastUserName = this.username + fs.writeFileSync(this.dataFilePath, JSON.stringify(this.loginData)) + } + + /** + * Searches for login data by API URL and user name. + * Returns undefined if nothing found by given keys. + */ + private getLoginRecord(apiUrl: string, username: string): RefreshTokenLoginRecord | undefined { + const serverLogins = this.loginData.logins![apiUrl] + if (!serverLogins) { + return + } + return serverLogins[username] + } + + /** + * Sets current login credentials by given API URL and username. + * If loginRecord is provided, then a new credentials are added, replacing existing if any. + * This method doesn't check credentials validity. + * Returns true if operation was successful. + */ + private setCurrentLoginContext(apiUrl: string, username: string, loginRecord?: RefreshTokenLoginRecord): boolean { + if (!loginRecord) { + // Find existing login context and make current + loginRecord = this.getLoginRecord(apiUrl, username) + if (!loginRecord) { + return false + } + } else { + // Set given login config as current + let serverLogins = this.loginData.logins![apiUrl] + if (!serverLogins) { + serverLogins = {} + this.loginData.logins![apiUrl] = serverLogins + } + serverLogins[username] = loginRecord + } + + this.apiUrl = apiUrl + this.username = username + return true + } + + private removeExpiredLogins(): void { + if (!this.loginData.logins) { + return + } + + const now = Date.now() / 1000 + for (const [apiUrl, serverLogins] of Object.entries(this.loginData.logins)) { + for (const [username, loginRecord] of Object.entries(serverLogins)) { + if (loginRecord.expires <= now) { + // Token is expired, delete it + delete serverLogins[username] + } + } + if (Object.keys(serverLogins).length < 1) { + // Delete server without logins + delete this.loginData.logins[apiUrl] + } + } + + // Check if current login is still present + if (!this.getLoginRecord(this.apiUrl, this.username)) { + this.loginData.lastLoginUrl = this.apiUrl = '' + this.loginData.lastUserName = this.username = '' + } + } + + private async retrieveKeycloakSettings(apiUrl: string): Promise { + const cheApi = CheApiClient.getInstance(apiUrl) + const keycloakSettings = await cheApi.getKeycloakSettings() + if (!keycloakSettings) { + // Single user mode + throw new Error(`Authentication is not supported on the server: "${apiUrl}"`) + } + return keycloakSettings + } + + /** + * Returns new Keycloak access token for current login session. + * Updates session timeout. + */ + public async getNewAccessToken(): Promise { + if (!this.apiUrl || !this.username) { + throw new Error('Login context is not set. Please login first.') + } + + const loginRecord = this.getLoginRecord(this.apiUrl, this.username) + if (!loginRecord) { + // Should never happen + throw new Error('Invalid login state') + } + + const keycloakAuthData = await this.keycloakAuth(this.apiUrl, loginRecord) + // Update refresh token + loginRecord.refreshToken = keycloakAuthData.refresh_token + this.removeExpiredLogins() + this.setCurrentLoginContext(this.apiUrl, this.username, loginRecord) + this.saveLoginData() + + return keycloakAuthData.access_token + } + + private async keycloakAuth(apiUrl: string, loginRecord: LoginRecord, cheKeycloakSettings?: CheKeycloakSettings): Promise { + if (!cheKeycloakSettings) { + cheKeycloakSettings = await this.retrieveKeycloakSettings(apiUrl) + } + if (isPasswordLoginData(loginRecord)) { + return this.getKeycloakAuthDataByUserNameAndPassword(cheKeycloakSettings, loginRecord.username, loginRecord.password) + } else { + if (isRefreshTokenLoginData(loginRecord)) { + return this.getKeycloakAuthDataByRefreshToken(cheKeycloakSettings, loginRecord.refreshToken) + } else if (isOcUserTokenLoginData(loginRecord)) { + return this.getKeycloakAuthDataByOcToken(cheKeycloakSettings, loginRecord.subjectToken, loginRecord.subjectIssuer) + } else { + // Should never happen + throw new Error('Token is not provided') + } + } + } + + private async getKeycloakAuthDataByUserNameAndPassword(cheKeycloakSettings: CheKeycloakSettings, username: string, password: string): Promise { + const keycloakTokenUrl = cheKeycloakSettings['che.keycloak.token.endpoint'] + const data = { + client_id: cheKeycloakSettings['che.keycloak.client_id'], + grant_type: 'password', + username, + password, + } + const headers = { + 'Content-Type': 'application/x-www-form-urlencoded' + } + try { + const response = await this.axios.post(keycloakTokenUrl, querystring.stringify(data), { headers, timeout: REQUEST_TIMEOUT_MS }) + if (!response || response.status !== 200 || !response.data) { + throw new Error('E_BAD_RESP_KEYCLOAK') + } + return response.data + } catch (error) { + let message = error.message + if (error && error.response && error.response.data && error.response.data.error_description) { + message = error.response.data.error_description + } + throw new Error(`Failed to get access token from ${keycloakTokenUrl}. Cause: ${message}`) + } + } + + private async getKeycloakAuthDataByRefreshToken(cheKeycloakSettings: CheKeycloakSettings, refreshToken: string): Promise { + const data = { + client_id: cheKeycloakSettings['che.keycloak.client_id'], + grant_type: 'refresh_token', + refresh_token: refreshToken, + } + return this.requestKeycloakAuth(cheKeycloakSettings['che.keycloak.token.endpoint'], data) + } + + private async getKeycloakAuthDataByOcToken(cheKeycloakSettings: CheKeycloakSettings, subjectToken: string, subjectIssuer: string): Promise { + const data = { + client_id: cheKeycloakSettings['che.keycloak.client_id'], + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + subject_token: subjectToken, + subject_issuer: subjectIssuer, + } + return this.requestKeycloakAuth(cheKeycloakSettings['che.keycloak.token.endpoint'], data) + } + + private async requestKeycloakAuth(keycloakTokenUrl: string, requestData: any): Promise { + const headers = { + 'Content-Type': 'application/x-www-form-urlencoded' + } + try { + const response = await this.axios.post(keycloakTokenUrl, querystring.stringify(requestData), { headers, timeout: REQUEST_TIMEOUT_MS }) + if (!response || response.status !== 200 || !response.data) { + throw new Error('E_BAD_RESP_KEYCLOAK') + } + return response.data + } catch (error) { + let message = error.message + if (error && error.response && error.response.data && error.response.data.error_description) { + message = error.response.data.error_description + } + throw new Error(`Failed to get the access token from ${keycloakTokenUrl}. Cause: ${message}`) + } + } + + private async getCurrentUserName(cheKeycloakSettings: CheKeycloakSettings, accessToken: string): Promise { + const endpoint = cheKeycloakSettings['che.keycloak.userinfo.endpoint'] + const headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `bearer ${accessToken}`, + } + try { + const response = await this.axios.get(endpoint, { headers, timeout: REQUEST_TIMEOUT_MS }) + if (!response || response.status !== 200 || !response.data) { + throw new Error('E_BAD_RESP_KEYCLOAK') + } + return response.data.preferred_username + } catch (error) { + throw new Error(`Failed to get userdata from ${endpoint}. Cause: ${error.message}`) + } + } + +} + +/** + * Helper function to get valid credentials. Designed to be used from commands. + * @param cheApiEndpoint user provided server API URL if any + * @param accessToken user provied access token if any + */ +export async function getLoginData(configDir: string, cheApiEndpoint?: string, accessToken?: string | undefined): Promise { + if (cheApiEndpoint) { + // User provides credential manually + const cheApiClient = CheApiClient.getInstance(cheApiEndpoint) + await cheApiClient.checkCheApiEndpointUrl() + if (!accessToken && await cheApiClient.isAuthenticationEnabled()) { + throw new Error(`Parameter "--${ACCESS_TOKEN_KEY}" is expected.`) + } + // Single user mode, proceed without token + } else { + // Use login manager to get Che API URL and token + const loginManager = await CheServerLoginManager.getInstance(configDir) + cheApiEndpoint = loginManager.getCurrentServerApiUrl() + if (!cheApiEndpoint) { + throw new Error('There is no active login session. Please use "server:login" first.') + } + accessToken = await loginManager.getNewAccessToken() + } + return { cheApiEndpoint, accessToken } +} diff --git a/src/commands/auth/delete.ts b/src/commands/auth/delete.ts new file mode 100644 index 000000000..05ea8f6a8 --- /dev/null +++ b/src/commands/auth/delete.ts @@ -0,0 +1,73 @@ +/********************************************************************* + * Copyright (c) 2020 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + **********************************************************************/ + +import { Command, flags } from '@oclif/command' +import { cli } from 'cli-ux' + +import { CheApiClient } from '../../api/che-api-client' +import { CheServerLoginManager } from '../../api/che-login-manager' +import { CHE_API_ENDPOINT_KEY, username, USERNAME_KEY } from '../../common-flags' + +export default class Delete extends Command { + static description = 'Delete specified login session(s)' + + static args = [ + { + name: CHE_API_ENDPOINT_KEY, + description: 'Eclipse Che server API endpoint', + required: true + } + ] + static flags: flags.Input = { + help: flags.help({ char: 'h' }), + [USERNAME_KEY]: username, + } + + static examples = [ + '# Delete login session of the specified user on the cluster:\n' + + 'chectl auth:delete che-che.apps-crc.testing/api -u username', + '\n\n# Delete all login sessions on the cluster:\n' + + 'chectl auth:delete che-che.apps-crc.testing', + ] + + async run() { + const { args, flags } = this.parse(Delete) + + let cheApiEndpoint = CheApiClient.normalizeCheApiEndpointUrl(args[CHE_API_ENDPOINT_KEY]) + const username: string | undefined = flags[USERNAME_KEY] + + const loginManager = await CheServerLoginManager.getInstance(this.config.configDir) + + if (!loginManager.hasLoginFor(cheApiEndpoint)) { + // Maybe /api suffix isn't provided + const cheApiEndpointGuess = cheApiEndpoint + '/api' + if (!loginManager.hasLoginFor(cheApiEndpointGuess)) { + cli.info(`No registered login sessions on server ${cheApiEndpoint}`) + return + } + cheApiEndpoint = cheApiEndpointGuess + } + + if (username) { + if (!loginManager.hasLoginFor(cheApiEndpoint, username)) { + cli.info(`${username} is not logged in on ${cheApiEndpoint}. Nothing to delete.`) + return + } + } + + loginManager.deleteLoginContext(cheApiEndpoint, username) + if (username) { + cli.info(`Succesfully logged out ${username} on ${cheApiEndpoint}`) + } else { + cli.info(`Succesfully logged out all users on ${cheApiEndpoint}`) + } + } + +} diff --git a/src/commands/auth/get.ts b/src/commands/auth/get.ts new file mode 100644 index 000000000..4c5098e90 --- /dev/null +++ b/src/commands/auth/get.ts @@ -0,0 +1,29 @@ +/********************************************************************* + * Copyright (c) 2020 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + **********************************************************************/ + +import { Command } from '@oclif/command' +import { cli } from 'cli-ux' + +import { CheServerLoginManager } from '../../api/che-login-manager' + +export default class Get extends Command { + static description = 'Display active login session' + + async run() { + const loginManager = await CheServerLoginManager.getInstance(this.config.configDir) + const currentLogin = loginManager.getCurrentLoginInfo() + if (currentLogin.username) { + cli.info(`Logged into ${currentLogin.cheApiEndpoint} as ${currentLogin.username}`) + } else { + cli.info('There is no active login session') + } + } + +} diff --git a/src/commands/auth/list.ts b/src/commands/auth/list.ts new file mode 100644 index 000000000..6b85da7c3 --- /dev/null +++ b/src/commands/auth/list.ts @@ -0,0 +1,47 @@ +/********************************************************************* + * Copyright (c) 2020 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + **********************************************************************/ + +import { Command } from '@oclif/command' +import { cli } from 'cli-ux' + +import { CheServerLoginManager } from '../../api/che-login-manager' + +export default class List extends Command { + static description = 'Show all existing login sessions' + + async run() { + const loginManager = await CheServerLoginManager.getInstance(this.config.configDir) + const logins = loginManager.getAllLogins() + const currentLogin = loginManager.getCurrentLoginInfo() + this.printLogins(logins, currentLogin) + } + + private printLogins(allLogins: Map, currentLogin: { cheApiEndpoint: string, username: string }): void { + const currentLoginMarker = ' * ' + const indent = ' ' + + let output: string + if (allLogins.size > 0) { + output = 'Available logins:\n' + allLogins.forEach((serverLogins: string[], serverUrl: string) => { + output += indent + serverUrl + '\n' + for (const login of serverLogins) { + output += (currentLogin.cheApiEndpoint === serverUrl && currentLogin.username === login) ? currentLoginMarker : indent + output += indent + login + '\n' + } + }) + } else { + output = 'There are no login sessions' + } + + cli.info(output) + } + +} diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts new file mode 100644 index 000000000..6f79a40d9 --- /dev/null +++ b/src/commands/auth/login.ts @@ -0,0 +1,160 @@ +/********************************************************************* + * Copyright (c) 2020 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + **********************************************************************/ + +import { Command, flags } from '@oclif/command' +import { string } from '@oclif/parser/lib/flags' +import { cli } from 'cli-ux' +import * as execa from 'execa' + +import { CheHelper } from '../../api/che' +import { CheApiClient } from '../../api/che-api-client' +import { CheServerLoginManager, LoginRecord } from '../../api/che-login-manager' +import { KubeHelper } from '../../api/kube' +import { cheNamespace, CHE_API_ENDPOINT_KEY, username, USERNAME_KEY } from '../../common-flags' +import { OPENSHIFT_CLI } from '../../util' + +const REFRESH_TOKEN_KEY = 'refresh-token' +const PASSWORD_KEY = 'password' + +export default class Login extends Command { + static description = 'Log in to Eclipse Che server' + + static args = [ + { + name: CHE_API_ENDPOINT_KEY, + description: 'Eclipse Che server API endpoint', + env: 'CHE_API_ENDPOINT', + required: false // In case of login via oc token with admin rights + } + ] + static flags: flags.Input = { + help: flags.help({ char: 'h' }), + chenamespace: cheNamespace, + [REFRESH_TOKEN_KEY]: string({ + char: 't', + description: 'Keycloak refresh token', + env: 'CHE_KEYCLOAK_REFRESH_TOKEN', + required: false, + exclusive: [USERNAME_KEY, PASSWORD_KEY] + }), + [USERNAME_KEY]: username, + [PASSWORD_KEY]: string({ + char: 'p', + description: 'Eclipse Che user password', + env: 'CHE_USER_PASSWORD', + required: false, + exclusive: [REFRESH_TOKEN_KEY] + }), + } + + static examples = [ + '# Log in with username and password (when OpenShift OAuth is not enabled):\n' + + 'chectl auth:login https://che-che.apps-crc.testing/api -u username -p password', + '\n\n# Log in with username and password (password will be asked interactively):\n' + + 'chectl auth:login che-che.apps-crc.testing -u username', + '\n\n# Log in with token (when OpenShift OAuth is enabled):\n' + + 'chectl auth:login che.openshift.io -t token', + '\n\n# Log in with oc token (when logged into an OpenShift cluster with oc and OpenShift OAuth is enabled):\n' + + 'chectl auth:login che.my.server.net', + ] + + async run() { + const { args, flags } = this.parse(Login) + + const loginManager = await CheServerLoginManager.getInstance(this.config.configDir) + + let cheApiClient: CheApiClient + let cheApiEndpoint: string | undefined = args[CHE_API_ENDPOINT_KEY] + if (!cheApiEndpoint) { + const kube = new KubeHelper(flags) + if (!await kube.hasReadPermissionsForNamespace(flags.chenamespace)) { + throw new Error('Please provide server API URL argument') + } + // Retrieve API URL from routes + const cheHelper = new CheHelper(flags) + cheApiEndpoint = await cheHelper.cheURL(flags.chenamespace) + '/api' + cli.info(`Using ${cheApiEndpoint} server API URL to log in`) + cheApiClient = CheApiClient.getInstance(cheApiEndpoint) + } else { + cheApiEndpoint = CheApiClient.normalizeCheApiEndpointUrl(cheApiEndpoint) + cheApiClient = CheApiClient.getInstance(cheApiEndpoint) + try { + await cheApiClient.checkCheApiEndpointUrl() + } catch (error) { + // Wrong API URL, try to guess, maybe base url is provided + if (!cheApiEndpoint.endsWith('api')) { + cheApiEndpoint += '/api' + cheApiClient = CheApiClient.getInstance(cheApiEndpoint) + await cheApiClient.checkCheApiEndpointUrl() + } else { + throw error + } + } + } + + if (!await cheApiClient.isAuthenticationEnabled()) { + cli.info(`Authentication is not supported on the server: "${cheApiEndpoint}"`) + return + } + + // Try to login user + const refreshToken: string | undefined = flags[REFRESH_TOKEN_KEY] + const username: string | undefined = flags[USERNAME_KEY] + + let loginData: LoginRecord | undefined + if (refreshToken) { + loginData = { refreshToken, expires: Date.now() / 1000 + 60 } + } else if (username) { + let password = flags[PASSWORD_KEY] + if (!password) { + // Password wasn't provided, ask user to input it + password = await cli.prompt(`Password for ${flags.username} on ${cheApiEndpoint}`, { type: 'hide' }) + if (!password) { + throw new Error('Password is required') + } + } + + loginData = { username, password } + } else { + // Try to login via oc login credentials + // Check for oc command and oc login credentials + const stdout = (await execa(OPENSHIFT_CLI, ['status'], { timeout: 10000 })).stdout + if (stdout.startsWith('In project')) { + // User is logged into cluster with oc or kubectl + // Try to retrieve oc user token + let ocUserToken: string + const getUserTokenArgs = ['whoami', '--show-token'] + try { + ocUserToken = (await execa(OPENSHIFT_CLI, getUserTokenArgs, { timeout: 10000 })).stdout + } catch { + // Che is running on a Kubernetes cluster + throw new Error(`No credentials provided. Please provide "--${REFRESH_TOKEN_KEY}" or "--${USERNAME_KEY}" parameter`) + } + + const kube = new KubeHelper() + const subjectIssuer = (await kube.isOpenShift4()) ? 'openshift-v4' : 'openshift-v3' + + loginData = { subjectToken: ocUserToken, subjectIssuer } + } + } + + if (!loginData) { + throw new Error('Login data is required. Please provide token or username and password.') + } + + try { + const username = await loginManager.setLoginContext(cheApiEndpoint, loginData) + cli.info(`Succesfully logged into ${cheApiEndpoint} as ${username}`) + } catch (error) { + cli.error(error) + } + } + +} diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts new file mode 100644 index 000000000..e3ad74875 --- /dev/null +++ b/src/commands/auth/logout.ts @@ -0,0 +1,34 @@ +/********************************************************************* + * Copyright (c) 2020 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + **********************************************************************/ + +import { Command } from '@oclif/command' +import { cli } from 'cli-ux' + +import { CheServerLoginManager } from '../../api/che-login-manager' + +export default class Logout extends Command { + static description = 'Log out of the active login session' + + async run() { + const loginManager = await CheServerLoginManager.getInstance(this.config.configDir) + const currentLogin = loginManager.getCurrentLoginInfo() + + const cheApiEndpoint = currentLogin.cheApiEndpoint + const username = currentLogin.username + if (!cheApiEndpoint || !username) { + cli.info('There is no active login session') + return + } + + loginManager.deleteLoginContext(cheApiEndpoint, username) + cli.info(`Succesfully logged out ${username} on ${cheApiEndpoint}`) + } + +} diff --git a/src/commands/auth/use.ts b/src/commands/auth/use.ts new file mode 100644 index 000000000..7acdc1847 --- /dev/null +++ b/src/commands/auth/use.ts @@ -0,0 +1,172 @@ +/********************************************************************* + * Copyright (c) 2020 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + **********************************************************************/ + +import { Command, flags } from '@oclif/command' +import { cli } from 'cli-ux' +import * as inquirer from 'inquirer' + +import { CheApiClient } from '../../api/che-api-client' +import { CheServerLoginManager } from '../../api/che-login-manager' +import { CHE_API_ENDPOINT_KEY, username, USERNAME_KEY } from '../../common-flags' + +export default class Use extends Command { + static description = 'Set active login session' + + static args = [ + { + name: CHE_API_ENDPOINT_KEY, + description: 'Eclipse Che server API endpoint', + required: false + } + ] + static flags: flags.Input = { + help: flags.help({ char: 'h' }), + [USERNAME_KEY]: username, + interactive: flags.boolean({ + char: 'i', + description: 'Select an active login session in interactive mode', + required: false, + exclusive: [USERNAME_KEY] + }), + } + + static examples = [ + '# Set an active login session for the specified user on the given cluster:\n' + + 'chectl auth:use che-che.apps-crc.testing/api -u username', + '\n\n# Switch to another user on the same cluster:\n' + + 'chectl auth:use -u another-user-on-this-server', + '\n\n# Switch to the only user on the given cluster:\n' + + 'chectl auth:use my.cluster.net', + '\n\n# Select an active login session in interactive mode:\n' + + 'chectl auth:use -i', + ] + + async run() { + const { args, flags } = this.parse(Use) + + if (flags.interactive) { + await this.interactiveSwitch() + return + } + + let cheApiEndpoint: string | undefined = args[CHE_API_ENDPOINT_KEY] + let username: string | undefined = flags[USERNAME_KEY] + + if (!cheApiEndpoint && !username) { + throw new Error('No arguments provided') + } + + const loginManager = await CheServerLoginManager.getInstance(this.config.configDir) + + if (!cheApiEndpoint) { + // Try to use current server + const currentLogin = loginManager.getCurrentLoginInfo() + cheApiEndpoint = currentLogin.cheApiEndpoint + if (!cheApiEndpoint) { + // There is no current server to switch user on + throw new Error('No current login session. Please specify it directly.') + } + + if (username === currentLogin.username) { + // This is already current context + cli.info(`Already logged in as ${username} on ${cheApiEndpoint} server`) + return + } + } else { + cheApiEndpoint = CheApiClient.normalizeCheApiEndpointUrl(cheApiEndpoint) + // Check if any login exist for provided Che server + if (!loginManager.hasLoginFor(cheApiEndpoint)) { + // Maybe /api suffix isn't provided + const cheApiEndpointGuess = cheApiEndpoint + '/api' + if (!loginManager.hasLoginFor(cheApiEndpointGuess)) { + cli.info(`No registered login sessions on server ${cheApiEndpoint}`) + return + } + cheApiEndpoint = cheApiEndpointGuess + } + } + + if (!username) { + // Check if given server has only one login session to use + const serverLogins = loginManager.getAllLogins().get(cheApiEndpoint) + if (!serverLogins || (serverLogins && serverLogins.length < 1)) { + cli.info(`No registered login sessions for ${cheApiEndpoint} server`) + return + } + if (serverLogins.length !== 1) { + throw new Error(`Username on ${cheApiEndpoint} server is expected. Please provide "--${USERNAME_KEY}" parameter`) + } + // Use the only logged in user on the server + username = serverLogins[0] + } + + await loginManager.switchLoginContext(cheApiEndpoint, username) + cli.info(`Now active login is ${username} on ${cheApiEndpoint} server`) + } + + private async interactiveSwitch(): Promise { + const loginManager = await CheServerLoginManager.getInstance(this.config.configDir) + const allLogins = loginManager.getAllLogins() + const currentLogin = loginManager.getCurrentLoginInfo() + + let cheApiEndpoint = '' + let username = '' + if (allLogins.size === 0) { + cli.info('No login session exists') + return + } else if (allLogins.size === 1) { + // Retrieve the only login info + cheApiEndpoint = allLogins.keys().next().value + username = allLogins.get(cheApiEndpoint)![0] + } else { + // Ask user to interactively select + const choices: inquirer.Answers[] = [] + let current: inquirer.Answers | undefined + allLogins.forEach((serverLogins: string[], serverUrl: string) => { + choices.push(new inquirer.Separator(serverUrl)) + for (const login of serverLogins) { + const choise = { + name: ` ${login}`, + value: { cheApiEndpoint: serverUrl, username: login } + } + choices.push(choise) + if (currentLogin.cheApiEndpoint === serverUrl && currentLogin.username === login) { + current = choise + } + } + }) + + const userResponse = await inquirer.prompt([{ + name: 'context', + type: 'list', + message: 'Select login session', + choices, + default: current ? current.value : undefined, + }]) + + if (userResponse && userResponse.context) { + cheApiEndpoint = userResponse.context.cheApiEndpoint + username = userResponse.context.username + } + } + + if (cheApiEndpoint && username) { + if (currentLogin.cheApiEndpoint === cheApiEndpoint && currentLogin.username === username) { + cli.info(`Already logged in as ${username} on ${cheApiEndpoint} server`) + return + } + await loginManager.switchLoginContext(cheApiEndpoint, username) + cli.info(`Now active login is ${username} on ${cheApiEndpoint} server`) + } else { + cli.info('Nothing to change') + } + } + +} diff --git a/src/commands/workspace/create.ts b/src/commands/workspace/create.ts index f5b7fbf72..887560b9e 100644 --- a/src/commands/workspace/create.ts +++ b/src/commands/workspace/create.ts @@ -16,7 +16,7 @@ import * as notifier from 'node-notifier' import { CheHelper } from '../../api/che' import { CheApiClient } from '../../api/che-api-client' -import { KubeHelper } from '../../api/kube' +import { getLoginData } from '../../api/che-login-manager' import { accessToken, ACCESS_TOKEN_KEY, cheApiEndpoint, cheNamespace, CHE_API_ENDPOINT_KEY, skipKubeHealthzCheck } from '../../common-flags' export default class Create extends Command { @@ -54,20 +54,10 @@ export default class Create extends Command { const { flags } = this.parse(Create) const devfilePath = this.getDevfilePath(flags.devfile) - const accessToken = flags[ACCESS_TOKEN_KEY] const cheHelper = new CheHelper(flags) - let cheApiEndpoint = flags[CHE_API_ENDPOINT_KEY] - if (!cheApiEndpoint) { - const kube = new KubeHelper(flags) - if (!await kube.hasReadPermissionsForNamespace(flags.chenamespace)) { - throw new Error(`Eclipse Che API endpoint is required. Use flag --${CHE_API_ENDPOINT_KEY} to provide it.`) - } - cheApiEndpoint = await cheHelper.cheURL(flags.chenamespace) + '/api' - } - + const { cheApiEndpoint, accessToken } = await getLoginData(this.config.configDir, flags[CHE_API_ENDPOINT_KEY], flags[ACCESS_TOKEN_KEY]) const cheApiClient = CheApiClient.getInstance(cheApiEndpoint) - await cheApiClient.checkCheApiEndpointUrl() let workspace = await cheHelper.createWorkspaceFromDevfile(cheApiEndpoint, devfilePath, flags.name, accessToken) const workspaceId = workspace.id! diff --git a/src/commands/workspace/delete.ts b/src/commands/workspace/delete.ts index fb6096b10..b05d53ad9 100644 --- a/src/commands/workspace/delete.ts +++ b/src/commands/workspace/delete.ts @@ -12,13 +12,13 @@ import { Command, flags } from '@oclif/command' import { cli } from 'cli-ux' import * as notifier from 'node-notifier' -import { CheHelper } from '../../api/che' import { CheApiClient } from '../../api/che-api-client' +import { getLoginData } from '../../api/che-login-manager' import { KubeHelper } from '../../api/kube' import { accessToken, ACCESS_TOKEN_KEY, cheApiEndpoint, cheNamespace, CHE_API_ENDPOINT_KEY, skipKubeHealthzCheck } from '../../common-flags' export default class Delete extends Command { - static description = 'delete a stopped workspace - use workspace:stop to stop the workspace before deleting it' + static description = 'Delete a stopped workspace - use workspace:stop to stop the workspace before deleting it' static flags: flags.Input = { help: flags.help({ char: 'h' }), @@ -45,27 +45,15 @@ export default class Delete extends Command { const workspaceId = args.workspace - let cheApiEndpoint = flags[CHE_API_ENDPOINT_KEY] - if (!cheApiEndpoint) { - const kube = new KubeHelper(flags) - if (!await kube.hasReadPermissionsForNamespace(flags.chenamespace)) { - throw new Error(`Eclipse Che API endpoint is required. Use flag --${CHE_API_ENDPOINT_KEY} to provide it.`) - } - - const cheHelper = new CheHelper(flags) - cheApiEndpoint = await cheHelper.cheURL(flags.chenamespace) + '/api' - } - + const { cheApiEndpoint, accessToken } = await getLoginData(this.config.configDir, flags[CHE_API_ENDPOINT_KEY], flags[ACCESS_TOKEN_KEY]) const cheApiClient = CheApiClient.getInstance(cheApiEndpoint) - await cheApiClient.checkCheApiEndpointUrl() - - const workspace = await cheApiClient.getWorkspaceById(workspaceId, flags[ACCESS_TOKEN_KEY]) - const infrastructureNamespace = workspace!.attributes!.infrastructureNamespace - - await cheApiClient.deleteWorkspaceById(workspaceId, flags[ACCESS_TOKEN_KEY]) + await cheApiClient.deleteWorkspaceById(workspaceId, accessToken) cli.log(`Workspace with id '${workspaceId}' deleted.`) if (flags['delete-namespace']) { + const workspace = await cheApiClient.getWorkspaceById(workspaceId, accessToken) + const infrastructureNamespace = workspace!.attributes!.infrastructureNamespace + if (infrastructureNamespace === flags.chenamespace) { cli.warn(`It is not possible to delete namespace '${infrastructureNamespace}' since it is used for Eclipse Che deployment.`) return diff --git a/src/commands/workspace/inject.ts b/src/commands/workspace/inject.ts index 1f39e1e89..3d677158b 100644 --- a/src/commands/workspace/inject.ts +++ b/src/commands/workspace/inject.ts @@ -19,12 +19,13 @@ import * as path from 'path' import { CheHelper } from '../../api/che' import { CheApiClient } from '../../api/che-api-client' +import { getLoginData } from '../../api/che-login-manager' import { KubeHelper } from '../../api/kube' import { accessToken, ACCESS_TOKEN_KEY, cheApiEndpoint, cheNamespace, CHE_API_ENDPOINT_KEY, skipKubeHealthzCheck } from '../../common-flags' import { getClusterClientCommand, OPENSHIFT_CLI } from '../../util' export default class Inject extends Command { - static description = 'inject configurations and tokens in a workspace' + static description = 'Inject configurations and tokens in a workspace' static flags: flags.Input = { help: flags.help({ char: 'h' }), @@ -62,26 +63,13 @@ export default class Inject extends Command { const notifier = require('node-notifier') const cheHelper = new CheHelper(flags) - let cheApiEndpoint = flags[CHE_API_ENDPOINT_KEY] - if (!cheApiEndpoint) { - const kube = new KubeHelper(flags) - if (!await kube.hasReadPermissionsForNamespace(flags.chenamespace)) { - throw new Error(`Eclipse Che API endpoint is required. Use flag --${CHE_API_ENDPOINT_KEY} to provide it.`) - } - cheApiEndpoint = await cheHelper.cheURL(flags.chenamespace) + '/api' - } - + const { cheApiEndpoint, accessToken } = await getLoginData(this.config.configDir, flags[CHE_API_ENDPOINT_KEY], flags[ACCESS_TOKEN_KEY]) const cheApiClient = CheApiClient.getInstance(cheApiEndpoint) - await cheApiClient.checkCheApiEndpointUrl() - - if (!flags[ACCESS_TOKEN_KEY] && await cheApiClient.isAuthenticationEnabled()) { - cli.error('Authentication is enabled but \'access-token\' is not provided.\nSee more details with the --help flag.') - } let workspaceId = flags.workspace let workspaceNamespace = '' if (!workspaceId) { - const workspaces = await cheApiClient.getAllWorkspaces(flags[ACCESS_TOKEN_KEY]) + const workspaces = await cheApiClient.getAllWorkspaces(accessToken) const runningWorkspaces = workspaces.filter(w => w.status === 'RUNNING') if (runningWorkspaces.length === 1) { workspaceId = runningWorkspaces[0].id @@ -92,7 +80,7 @@ export default class Inject extends Command { cli.error('There are more than 1 running workspaces. Please, specify the workspace id by providing \'--workspace\' flag.\nSee more details with the --help flag.') } } else { - const workspace = await cheApiClient.getWorkspaceById(workspaceId, flags[ACCESS_TOKEN_KEY]) + const workspace = await cheApiClient.getWorkspaceById(workspaceId, accessToken) if (workspace.status !== 'RUNNING') { cli.error(`Workspace '${workspaceId}' is not running. Please start workspace first.`) } diff --git a/src/commands/workspace/list.ts b/src/commands/workspace/list.ts index 4973e6049..7dbfac373 100644 --- a/src/commands/workspace/list.ts +++ b/src/commands/workspace/list.ts @@ -11,13 +11,12 @@ import { Command, flags } from '@oclif/command' import { cli } from 'cli-ux' -import { CheHelper } from '../../api/che' import { CheApiClient } from '../../api/che-api-client' -import { KubeHelper } from '../../api/kube' -import { accessToken, ACCESS_TOKEN_KEY, cheApiEndpoint, cheNamespace, CHE_API_ENDPOINT_KEY as CHE_API_ENDPOINT_KEY, skipKubeHealthzCheck } from '../../common-flags' +import { getLoginData } from '../../api/che-login-manager' +import { accessToken, ACCESS_TOKEN_KEY, cheApiEndpoint, cheNamespace, CHE_API_ENDPOINT_KEY, skipKubeHealthzCheck } from '../../common-flags' export default class List extends Command { - static description = 'list workspaces' + static description = 'List workspaces' static flags: flags.Input = { help: flags.help({ char: 'h' }), @@ -30,22 +29,9 @@ export default class List extends Command { async run() { const { flags } = this.parse(List) - let workspaces = [] - let cheApiEndpoint = flags[CHE_API_ENDPOINT_KEY] - if (!cheApiEndpoint) { - const kube = new KubeHelper(flags) - if (!await kube.hasReadPermissionsForNamespace(flags.chenamespace)) { - throw new Error(`Eclipse Che API endpoint is required. Use flag --${CHE_API_ENDPOINT_KEY} to provide it.`) - } - - const cheHelper = new CheHelper(flags) - cheApiEndpoint = await cheHelper.cheURL(flags.chenamespace) + '/api' - } - + const { cheApiEndpoint, accessToken } = await getLoginData(this.config.configDir, flags[CHE_API_ENDPOINT_KEY], flags[ACCESS_TOKEN_KEY]) const cheApiClient = CheApiClient.getInstance(cheApiEndpoint) - await cheApiClient.checkCheApiEndpointUrl() - - workspaces = await cheApiClient.getAllWorkspaces(flags[ACCESS_TOKEN_KEY]) + const workspaces = await cheApiClient.getAllWorkspaces(accessToken) this.printWorkspaces(workspaces) } diff --git a/src/commands/workspace/start.ts b/src/commands/workspace/start.ts index f3b7be7c4..6e93c4b6b 100644 --- a/src/commands/workspace/start.ts +++ b/src/commands/workspace/start.ts @@ -14,7 +14,7 @@ import * as notifier from 'node-notifier' import { CheHelper } from '../../api/che' import { CheApiClient } from '../../api/che-api-client' -import { KubeHelper } from '../../api/kube' +import { getLoginData } from '../../api/che-login-manager' import { accessToken, ACCESS_TOKEN_KEY, cheApiEndpoint, cheNamespace, CHE_API_ENDPOINT_KEY, skipKubeHealthzCheck } from '../../common-flags' export default class Start extends Command { @@ -48,21 +48,11 @@ export default class Start extends Command { const workspaceId = args.workspace const cheHelper = new CheHelper(flags) - let cheApiEndpoint = flags[CHE_API_ENDPOINT_KEY] - if (!cheApiEndpoint) { - const kube = new KubeHelper(flags) - if (!await kube.hasReadPermissionsForNamespace(flags.chenamespace)) { - throw new Error(`Eclipse Che API endpoint is required. Use flag --${CHE_API_ENDPOINT_KEY} to provide it.`) - } - cheApiEndpoint = await cheHelper.cheURL(flags.chenamespace) + '/api' - } - + const { cheApiEndpoint, accessToken } = await getLoginData(this.config.configDir, flags[CHE_API_ENDPOINT_KEY], flags[ACCESS_TOKEN_KEY]) const cheApiClient = CheApiClient.getInstance(cheApiEndpoint) - await cheApiClient.checkCheApiEndpointUrl() - - await cheApiClient.startWorkspace(workspaceId, flags.debug, flags[ACCESS_TOKEN_KEY]) + await cheApiClient.startWorkspace(workspaceId, flags.debug, accessToken) - const workspace = await cheApiClient.getWorkspaceById(workspaceId, flags[ACCESS_TOKEN_KEY]) + const workspace = await cheApiClient.getWorkspaceById(workspaceId, accessToken) if (workspace.links && workspace.links.ide) { const workspaceIdeURL = await cheHelper.buildDashboardURL(workspace.links.ide) cli.log('Workspace start request has been sent, workspace will be available shortly:') diff --git a/src/commands/workspace/stop.ts b/src/commands/workspace/stop.ts index 2f0abe30d..f5aedf3cc 100644 --- a/src/commands/workspace/stop.ts +++ b/src/commands/workspace/stop.ts @@ -12,9 +12,8 @@ import { Command, flags } from '@oclif/command' import { cli } from 'cli-ux' import * as notifier from 'node-notifier' -import { CheHelper } from '../../api/che' import { CheApiClient } from '../../api/che-api-client' -import { KubeHelper } from '../../api/kube' +import { getLoginData } from '../../api/che-login-manager' import { accessToken, ACCESS_TOKEN_KEY, cheApiEndpoint, cheNamespace, CHE_API_ENDPOINT_KEY, skipKubeHealthzCheck } from '../../common-flags' export default class Stop extends Command { @@ -42,21 +41,9 @@ export default class Stop extends Command { const workspaceId = args.workspace - let cheApiEndpoint = flags[CHE_API_ENDPOINT_KEY] - if (!cheApiEndpoint) { - const kube = new KubeHelper(flags) - if (!await kube.hasReadPermissionsForNamespace(flags.chenamespace)) { - throw new Error(`Eclipse Che API endpoint is required. Use flag --${CHE_API_ENDPOINT_KEY} to provide it.`) - } - - const cheHelper = new CheHelper(flags) - cheApiEndpoint = await cheHelper.cheURL(flags.chenamespace) + '/api' - } - + const { cheApiEndpoint, accessToken } = await getLoginData(this.config.configDir, flags[CHE_API_ENDPOINT_KEY], flags[ACCESS_TOKEN_KEY]) const cheApiClient = CheApiClient.getInstance(cheApiEndpoint) - await cheApiClient.checkCheApiEndpointUrl() - - await cheApiClient.stopWorkspace(workspaceId, flags[ACCESS_TOKEN_KEY]) + await cheApiClient.stopWorkspace(workspaceId, accessToken) cli.log('Workspace successfully stopped.') notifier.notify({ diff --git a/src/common-flags.ts b/src/common-flags.ts index 648f48c4f..e05b09787 100644 --- a/src/common-flags.ts +++ b/src/common-flags.ts @@ -59,3 +59,11 @@ export const cheOperatorCRPatchYaml = string({ description: 'Path to a yaml file that overrides the default values in CheCluster CR used by the operator. This parameter is used only when the installer is the \'operator\' or the \'olm\'.', default: '' }) + +export const USERNAME_KEY = 'username' +export const username = string({ + char: 'u', + description: 'Eclipse Che username', + env: 'CHE_USER_NAME', + required: false, +}) diff --git a/test/e2e/minikube.test.ts b/test/e2e/minikube.test.ts index d9e61fadb..ef2c3c1ed 100644 --- a/test/e2e/minikube.test.ts +++ b/test/e2e/minikube.test.ts @@ -7,6 +7,9 @@ * * SPDX-License-Identifier: EPL-2.0 **********************************************************************/ + + // tslint:disable: no-console + import { expect, test } from '@oclif/test' import * as execa from 'execa' @@ -38,6 +41,37 @@ describe('Eclipse Che deploy test suite', () => { }) }) +describe('Che server authentication', () => { + it('Should login in to Che server with username and password', async () => { + const cheApiEndpoint = await helper.K8SHostname('che') + '/api' + + const command = `${binChectl} auth:login` + const args = [cheApiEndpoint, '-u', 'admin', '-p', 'admin'] + + const { exitCode, stdout, stderr } = await execa(command, args, { timeout: 30000, shell: true }) + + expect(exitCode).equal(0) + console.log(stdout) + + if (exitCode !== 0) { + console.log(stderr) + } + }) + + it('Should show current login session', async () => { + const command = `${binChectl} auth:get` + + const { exitCode, stdout, stderr } = await execa(command, { timeout: 30000, shell: true }) + + expect(exitCode).equal(0) + console.log(stdout) + + if (exitCode !== 0) { + console.log(stderr) + } + }) +}) + describe('Export CA certificate', () => { it('Export CA certificate', async () => { const command = `${binChectl} cacert:export` diff --git a/test/e2e/minishift.test.ts b/test/e2e/minishift.test.ts index be471258f..a161e8707 100644 --- a/test/e2e/minishift.test.ts +++ b/test/e2e/minishift.test.ts @@ -7,6 +7,9 @@ * * SPDX-License-Identifier: EPL-2.0 **********************************************************************/ + + // tslint:disable: no-console + import { expect, test } from '@oclif/test' import * as execa from 'execa' @@ -44,6 +47,37 @@ describe('Eclipse Che deploy test suite', () => { }) }) +describe('Che server authentication', () => { + it('Should login in to Che server with username and password', async () => { + const cheApiEndpoint = await helper.OCHostname('che') + '/api' + + const command = `${binChectl} auth:login` + const args = [cheApiEndpoint, '-u', 'admin', '-p', 'admin'] + + const { exitCode, stdout, stderr } = await execa(command, args, { timeout: 30000, shell: true }) + + expect(exitCode).equal(0) + console.log(stdout) + + if (exitCode !== 0) { + console.log(stderr) + } + }) + + it('Should show current login session', async () => { + const command = `${binChectl} auth:get` + + const { exitCode, stdout, stderr } = await execa(command, { timeout: 30000, shell: true }) + + expect(exitCode).equal(0) + console.log(stdout) + + if (exitCode !== 0) { + console.log(stderr) + } + }) +}) + describe('Export CA certificate', () => { it('Export CA certificate', async () => { const command = `${binChectl} cacert:export` diff --git a/test/e2e/openshift.test.ts b/test/e2e/openshift.test.ts index 18f0ee124..7b3c7b4f4 100644 --- a/test/e2e/openshift.test.ts +++ b/test/e2e/openshift.test.ts @@ -7,6 +7,9 @@ * * SPDX-License-Identifier: EPL-2.0 **********************************************************************/ + + // tslint:disable: no-console + import { expect, test } from '@oclif/test' import * as execa from 'execa' @@ -44,6 +47,37 @@ describe('Eclipse Che deploy test suite', () => { }) }) +describe('Che server authentication', () => { + it('Should login in to Che server with username and password', async () => { + const cheApiEndpoint = await helper.OCHostname('che') + '/api' + + const command = `${binChectl} auth:login` + const args = [cheApiEndpoint, '-u', 'admin', '-p', 'admin'] + + const { exitCode, stdout, stderr } = await execa(command, args, { timeout: 30000, shell: true }) + + expect(exitCode).equal(0) + console.log(stdout) + + if (exitCode !== 0) { + console.log(stderr) + } + }) + + it('Should show current login session', async () => { + const command = `${binChectl} auth:get` + + const { exitCode, stdout, stderr } = await execa(command, { timeout: 30000, shell: true }) + + expect(exitCode).equal(0) + console.log(stdout) + + if (exitCode !== 0) { + console.log(stderr) + } + }) +}) + describe('Export CA certificate', () => { it('Export CA certificate', async () => { const command = `${binChectl} cacert:export` diff --git a/yarn.lock b/yarn.lock index a489abc13..58c5bdd40 100644 --- a/yarn.lock +++ b/yarn.lock @@ -802,6 +802,14 @@ dependencies: "@types/node" "*" +"@types/inquirer@^7.3.1": + version "7.3.1" + resolved "https://registry.yarnpkg.com/@types/inquirer/-/inquirer-7.3.1.tgz#1f231224e7df11ccfaf4cf9acbcc3b935fea292d" + integrity sha512-osD38QVIfcdgsPCT0V3lD7eH0OFurX71Jft18bZrsVQWVRt6TuxRzlr0GJLrxoHZR2V5ph7/qP8se/dcnI7o0g== + dependencies: + "@types/through" "*" + rxjs "^6.4.0" + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762" @@ -946,6 +954,13 @@ "@types/minipass" "*" "@types/node" "*" +"@types/through@*": + version "0.0.30" + resolved "https://registry.yarnpkg.com/@types/through/-/through-0.0.30.tgz#e0e42ce77e897bd6aead6f6ea62aeb135b8a3895" + integrity sha512-FvnCJljyxhPM3gkRgWmxmDZyAQSiBQQWLI0A0VFL0K7W1oRUrPJSqNO0NvTnLkBcotdlp3lKvaT0JrnyRDkzOg== + dependencies: + "@types/node" "*" + "@types/tough-cookie@*": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.0.tgz#fef1904e4668b6e5ecee60c52cc6a078ffa6697d" @@ -1584,6 +1599,11 @@ char-regex@^1.0.2: resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== +chardet@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" + integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== + check-error@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82" @@ -1654,6 +1674,13 @@ cli-cursor@^2.0.0, cli-cursor@^2.1.0: dependencies: restore-cursor "^2.0.0" +cli-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" + integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== + dependencies: + restore-cursor "^3.1.0" + cli-progress@^3.4.0: version "3.8.2" resolved "https://registry.yarnpkg.com/cli-progress/-/cli-progress-3.8.2.tgz#abaf1fc6d6401351f16f068117a410554a0eb8c7" @@ -1729,6 +1756,11 @@ cli-ux@^5.2.1, cli-ux@^5.4.5: supports-hyperlinks "^2.1.0" tslib "^2.0.0" +cli-width@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-3.0.0.tgz#a2f48437a2caa9a22436e794bf071ec9e61cedf6" + integrity sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw== + cliui@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" @@ -2102,11 +2134,11 @@ ecc-jsbn@~0.1.1: "eclipse-che-operator@git://github.com/eclipse/che-operator#master": version "0.0.0" - resolved "git://github.com/eclipse/che-operator#227046459c196fe8b95fd66a444a69808cac97e3" + resolved "git://github.com/eclipse/che-operator#40b3904d69f9c588d67d37d61fe92120a68f8c3a" "eclipse-che@git://github.com/eclipse/che#master": version "0.0.0" - resolved "git://github.com/eclipse/che#00591be5ff0cd25d7dbf340adb419cae64e2f060" + resolved "git://github.com/eclipse/che#b1e484cb1bbc3eb9be2b7d14b70f3cf8d2172046" editorconfig@^0.15.0: version "0.15.3" @@ -2319,6 +2351,15 @@ extend@~3.0.2: resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== +external-editor@^3.0.3: + version "3.1.0" + resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495" + integrity sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew== + dependencies: + chardet "^0.7.0" + iconv-lite "^0.4.24" + tmp "^0.0.33" + extglob@^0.3.1: version "0.3.2" resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1" @@ -2429,6 +2470,13 @@ figures@^2.0.0: dependencies: escape-string-regexp "^1.0.5" +figures@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" + integrity sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg== + dependencies: + escape-string-regexp "^1.0.5" + file-uri-to-path@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" @@ -2894,7 +2942,7 @@ hyperlinker@^1.0.0: resolved "https://registry.yarnpkg.com/hyperlinker/-/hyperlinker-1.0.0.tgz#23dc9e38a206b208ee49bc2d6c8ef47027df0c0e" integrity sha512-Ty8UblRWFEcfSuIaajM34LdPXIhbs1ajEX/BBPv24J+enSVaEVY63xQ6lTO9VRYS5LAoghIG0IDJ+p+IPzKUQQ== -iconv-lite@0.4.24: +iconv-lite@0.4.24, iconv-lite@^0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== @@ -2947,6 +2995,25 @@ inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== +inquirer@^7.3.3: + version "7.3.3" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.3.3.tgz#04d176b2af04afc157a83fd7c100e98ee0aad003" + integrity sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA== + dependencies: + ansi-escapes "^4.2.1" + chalk "^4.1.0" + cli-cursor "^3.1.0" + cli-width "^3.0.0" + external-editor "^3.0.3" + figures "^3.0.0" + lodash "^4.17.19" + mute-stream "0.0.8" + run-async "^2.4.0" + rxjs "^6.6.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + through "^2.3.6" + interpret@^1.0.0: version "1.4.0" resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e" @@ -4223,6 +4290,11 @@ ms@^2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +mute-stream@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" + integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== + nan@^2.12.1: version "2.14.1" resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01" @@ -4477,6 +4549,11 @@ optionator@^0.8.1: type-check "~0.3.2" word-wrap "~1.2.3" +os-tmpdir@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" + integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= + p-any@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/p-any/-/p-any-1.1.0.tgz#1d03835c7eed1e34b8e539c47b7b60d0d015d4e1" @@ -4784,6 +4861,11 @@ query-string@^5.0.1: object-assign "^4.1.0" strict-uri-encode "^1.0.0" +querystring@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" + integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA= + randomatic@^3.0.0: version "3.1.1" resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-3.1.1.tgz#b776efc59375984e36c537b2f51a1f0aff0da1ed" @@ -4998,6 +5080,14 @@ restore-cursor@^2.0.0: onetime "^2.0.0" signal-exit "^3.0.2" +restore-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e" + integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== + dependencies: + onetime "^5.1.0" + signal-exit "^3.0.2" + ret@~0.1.10: version "0.1.15" resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" @@ -5032,12 +5122,17 @@ rsvp@^4.8.4: resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734" integrity sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA== +run-async@^2.4.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" + integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ== + run-parallel@^1.1.9: version "1.1.9" resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.1.9.tgz#c9dd3a7cf9f4b2c4b6244e173a6ed866e61dd679" integrity sha512-DEqnSRTDw/Tc3FXf49zedI638Z9onwUotBMiUFKmrO2sdFKIbXamXGQ3Axd4qgphxKB4kw/qP1w5kTxnfU1B9Q== -rxjs@^6.3.3, rxjs@^6.5.1: +rxjs@^6.3.3, rxjs@^6.4.0, rxjs@^6.5.1, rxjs@^6.6.0: version "6.6.3" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.3.tgz#8ca84635c4daa900c0d3967a6ee7ac60271ee552" integrity sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ== @@ -5590,6 +5685,11 @@ throat@^5.0.0: resolved "https://registry.yarnpkg.com/throat/-/throat-5.0.0.tgz#c5199235803aad18754a667d659b5e72ce16764b" integrity sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA== +through@^2.3.6: + version "2.3.8" + resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= + timed-out@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f" @@ -5602,6 +5702,13 @@ tmp-promise@^3.0.2: dependencies: tmp "^0.2.0" +tmp@^0.0.33: + version "0.0.33" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" + integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== + dependencies: + os-tmpdir "~1.0.2" + tmp@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.1.0.tgz#ee434a4e22543082e294ba6201dcc6eafefa2877"