Skip to content

Commit

Permalink
feat: Add Device Authentication flow support
Browse files Browse the repository at this point in the history
Signed-off-by: Roman Nikitenko <rnikiten@redhat.com>
  • Loading branch information
RomanNikitenko committed Oct 20, 2023
1 parent e6b0afa commit ae3bdab
Show file tree
Hide file tree
Showing 22 changed files with 1,110 additions and 158 deletions.
17 changes: 17 additions & 0 deletions code/extensions/che-api/src/api/github-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,23 @@ export const GithubService = Symbol('GithubService');

export interface GithubService {
getToken(): Promise<string>;

/**
* Updates in-memory value of the token,
* use {@link GithubService.persistToken()} to store a token to the corresponding secret
*/
updateCachedToken(token: string): Promise<void>;

/**
* Persists the given token to the corresponding secret, the in-memory value will be updated as well.
* A new secret will be created if there is no secret yet.
* Note: The existing token will be owerriten by the given one.
*/
persistToken(token: string): Promise<void>;

/* Returns true if the 'git-credentials-secret' already exists */
githubTokenSecretExists(): Promise<boolean>;

getUser(): Promise<GithubUser>;
getTokenScopes(token: string): Promise<string[]>;
}
2 changes: 2 additions & 0 deletions code/extensions/che-api/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { GithubServiceImpl } from './impl/github-service-impl';
import { TelemetryService } from './api/telemetry-service';
import { K8sTelemetryServiceImpl } from './impl/k8s-telemetry-service-impl';
import * as axios from 'axios';
import { Logger } from './logger';


export async function activate(_extensionContext: vscode.ExtensionContext): Promise<Api> {
Expand All @@ -42,6 +43,7 @@ export async function activate(_extensionContext: vscode.ExtensionContext): Prom
container.bind(GithubServiceImpl).toSelf().inSingletonScope();
container.bind(GithubService).to(GithubServiceImpl).inSingletonScope();
container.bind(TelemetryService).to(K8sTelemetryServiceImpl).inSingletonScope();
container.bind(Logger).toSelf().inSingletonScope();

const devfileService = container.get(DevfileService) as DevfileService;
const workspaceService = container.get(WorkspaceService) as WorkspaceService;
Expand Down
99 changes: 95 additions & 4 deletions code/extensions/che-api/src/impl/github-service-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,32 @@

/* eslint-disable header/header */

import { GithubService, GithubUser } from '../api/github-service';
import { inject, injectable } from 'inversify';
import * as k8s from '@kubernetes/client-node';
import { AxiosInstance } from 'axios';
import * as fs from 'fs-extra';
import { inject, injectable } from 'inversify';
import * as path from 'path';
import { GithubService, GithubUser } from '../api/github-service';
import { Logger } from '../logger';
import { K8SServiceImpl } from './k8s-service-impl';
import { base64Encode, createLabelsSelector, randomString } from './utils';

const GIT_CREDENTIAL_LABEL = {
'controller.devfile.io/git-credential': 'true'
};
const SCM_URL_ATTRIBUTE = 'che.eclipse.org/scm-url';
const GITHUB_URL = 'https://github.com';
const GIT_CREDENTIALS_LABEL_SELECTOR: string = createLabelsSelector(GIT_CREDENTIAL_LABEL);

@injectable()
export class GithubServiceImpl implements GithubService {
private readonly token: string | undefined;
private token: string | undefined;

constructor(@inject(Symbol.for('AxiosInstance')) private readonly axiosInstance: AxiosInstance) {
constructor(
@inject(Logger) private logger: Logger,
@inject(K8SServiceImpl) private readonly k8sService: K8SServiceImpl,
@inject(Symbol.for('AxiosInstance')) private readonly axiosInstance: AxiosInstance
) {
const credentialsPath = path.resolve('/.git-credentials', 'credentials');
if (fs.existsSync(credentialsPath)) {
const token = fs.readFileSync(credentialsPath).toString();
Expand Down Expand Up @@ -54,4 +69,80 @@ export class GithubServiceImpl implements GithubService {
});
return result.headers['x-oauth-scopes'].split(', ');
}

async updateCachedToken(token: string): Promise<void> {
this.token = token;
}

async persistToken(token: string): Promise<void> {
this.updateCachedToken(token);
this.logger.info(`Github Service: adding token to the git-credentials secret...`);

const gitCredentialSecret = await this.getGithubSecret();
if (!gitCredentialSecret) {
this.logger.info(`Github Service: git-credentials secret not found, creating a new secret...`);

const namespace = this.k8sService.getDevWorkspaceNamespace();
const newSecret = this.toSecret(namespace, token);
this.k8sService.createNamespacedSecret(newSecret);

this.logger.info(`Github Service: git-credentials secret was created successfully!`);
return;
}

this.logger.info(`Github Service: updating exsting git-credentials secret...`);

const data = {
credentials: base64Encode(`https://oauth2:${token}@github.com`)
};

const updatedSecret = { ...gitCredentialSecret, data };
const name = gitCredentialSecret.metadata?.name || `git-credentials-secret-${randomString(5).toLowerCase()}`;
this.k8sService.replaceNamespacedSecret(name, updatedSecret);

this.logger.info(`Github Service: git-credentials secret was updated successfully!`);
}

async githubTokenSecretExists(): Promise<boolean> {
const gitCredentialSecret = await this.getGithubSecret();
return !!gitCredentialSecret;
}

private async getGithubSecret(): Promise<k8s.V1Secret | undefined> {
this.logger.info(`Github Service: looking for the corresponding git-credentials secret...`);

const gitCredentialSecrets = await this.k8sService.getSecret(GIT_CREDENTIALS_LABEL_SELECTOR);
this.logger.info(`Github Service: found ${gitCredentialSecrets.length} secrets`);

if (gitCredentialSecrets.length === 0) {
return undefined
}

if (gitCredentialSecrets.length === 1) {
return gitCredentialSecrets[0];
}
return gitCredentialSecrets.find(secret => secret.metadata?.annotations?.[SCM_URL_ATTRIBUTE] === GITHUB_URL) || gitCredentialSecrets[0];
}

private toSecret(namespace: string, token: string): k8s.V1Secret {
return {
apiVersion: 'v1',
kind: 'Secret',
metadata: {
name: `git-credentials-secret-${randomString(5).toLowerCase()}`,
namespace,
labels: {
'controller.devfile.io/git-credential': 'true',
'controller.devfile.io/watch-secret': 'true',
},
annotations: {
'che.eclipse.org/scm-url': 'https://github.com'
}
},
type: 'Opaque',
data: {
credentials: base64Encode(`https://oauth2:${token}@github.com`)
},
};
}
}
133 changes: 126 additions & 7 deletions code/extensions/che-api/src/impl/k8s-service-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@

/* eslint-disable header/header */
import * as k8s from '@kubernetes/client-node';
import {
devworkspaceGroup,
devworkspaceLatestVersion,
devworkspacePlural,
V1alpha2DevWorkspace
} from '@devfile/api';

import { ApiType } from '@kubernetes/client-node';
import { injectable } from 'inversify';
Expand All @@ -19,17 +25,21 @@ const request = require('request');

@injectable()
export class K8SServiceImpl implements K8SService {
private kc: k8s.KubeConfig;
private coreV1API!: k8s.CoreV1Api;
private customObjectsApi!: k8s.CustomObjectsApi;
private k8sConfig: k8s.KubeConfig;
private devWorkspaceName!: string;
private devWorkspaceNamespace!: string;

constructor() {
this.kc = new k8s.KubeConfig();
this.kc.loadFromCluster();
this.k8sConfig = new k8s.KubeConfig();
this.k8sConfig.loadFromCluster();
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
sendRawQuery(requestURL: string, opts: any): Promise<K8SRawResponse> {
this.kc.applyToRequest(opts);
const cluster = this.kc.getCurrentCluster();
this.k8sConfig.applyToRequest(opts);
const cluster = this.k8sConfig.getCurrentCluster();
if (!cluster) {
throw new Error('K8S cluster is not defined');
}
Expand All @@ -52,10 +62,119 @@ export class K8SServiceImpl implements K8SService {
}

getConfig(): k8s.KubeConfig {
return this.kc;
return this.k8sConfig;
}

makeApiClient<T extends ApiType>(apiClientType: new (server: string) => T): T {
return this.kc.makeApiClient(apiClientType);
return this.k8sConfig.makeApiClient(apiClientType);
}

getCoreApi(): k8s.CoreV1Api {
if (!this.coreV1API) {
this.k8sConfig.loadFromCluster();
this.coreV1API = this.makeApiClient(k8s.CoreV1Api);
}
return this.coreV1API;
}

getCustomObjectsApi(): k8s.CustomObjectsApi {
if (!this.customObjectsApi) {
this.k8sConfig.loadFromCluster();
this.customObjectsApi = this.makeApiClient(k8s.CustomObjectsApi);
}
return this.customObjectsApi;
}

async getSecret(labelSelector?: string): Promise<Array<k8s.V1Secret>> {
const coreV1API = this.getCoreApi();
const namespace = this.getDevWorkspaceNamespace();
if (!namespace) {
throw new Error('Can not get secrets: DEVWORKSPACE_NAMESPACE env variable is not defined');
}

try {
const { body } = await coreV1API.listNamespacedSecret(
namespace,
undefined,
undefined,
undefined,
undefined,
labelSelector,
);
return body.items;
} catch (error) {
console.error('Can not get secret ', error);
return [];
}
}

async replaceNamespacedSecret(name: string, secret: k8s.V1Secret): Promise<void> {
const namespace = this.getDevWorkspaceNamespace();
if (!namespace) {
throw new Error('Can not replace a secret: DEVWORKSPACE_NAMESPACE env variable is not defined');
}

const coreV1API = this.getCoreApi();
await coreV1API.replaceNamespacedSecret(name, namespace, secret);
}

async createNamespacedSecret(secret: k8s.V1Secret): Promise<void> {
const namespace = this.getDevWorkspaceNamespace();
if (!namespace) {
throw new Error('Can not create a secret: DEVWORKSPACE_NAMESPACE env variable is not defined');
}

const coreV1API = this.getCoreApi();
await coreV1API.createNamespacedSecret(namespace, secret);
}

async getDevWorkspace(): Promise<V1alpha2DevWorkspace> {
try {
const workspaceName = this.getDevWorkspaceName();
const namespace = this.getDevWorkspaceNamespace();
const customObjectsApi = this.getCustomObjectsApi();

const resp = await customObjectsApi.getNamespacedCustomObject(
devworkspaceGroup,
devworkspaceLatestVersion,
namespace,
devworkspacePlural,
workspaceName,
);
return resp.body as V1alpha2DevWorkspace;
} catch (e) {
console.error(e);
throw new Error('Unable to get Dev Workspace');
}
}

getDevWorkspaceName(): string {
if (this.devWorkspaceName) {
return this.devWorkspaceName;
}

const workspaceName = process.env.DEVWORKSPACE_NAME;
if (workspaceName) {
this.devWorkspaceName = workspaceName;
return this.devWorkspaceName;
}

console.error('Can not get Dev Workspace name: DEVWORKSPACE_NAME env variable is not defined');
throw new Error('Can not get Dev Workspace name');
}

getDevWorkspaceNamespace(): string {
if (this.devWorkspaceNamespace) {
return this.devWorkspaceNamespace;
}

const workspaceNamespace = process.env.DEVWORKSPACE_NAMESPACE;
if (workspaceNamespace) {
this.devWorkspaceNamespace = workspaceNamespace;
return this.devWorkspaceNamespace;
}

console.error('Can not get Dev Workspace namespace: DEVWORKSPACE_NAMESPACE env variable is not defined');
throw new Error('Can not get Dev Workspace namespace');
}
}
49 changes: 49 additions & 0 deletions code/extensions/che-api/src/impl/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**********************************************************************
* Copyright (c) 2023 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
***********************************************************************/

/* eslint-disable header/header */

export function arrayEquals<T>(first: ReadonlyArray<T> | undefined, second: ReadonlyArray<T> | undefined, itemEquals: (a: T, b: T) => boolean = (a, b) => a === b): boolean {
if (first === second) {
return true;
}
if (!first || !second) {
return false;
}
if (first.length !== second.length) {
return false;
}
for (let i = 0, len = first.length; i < len; i++) {
if (!itemEquals(first[i], second[i])) {
return false;
}
}
return true;
}

export function base64Encode(toEncode: string): string {
return Buffer.from(toEncode, 'binary').toString('base64');
}

export function randomString(length: number): string {
let result = '';
while (result.length < length) {
result += 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'[
(Math.random() * 60) | 0
];
}
return result;
}

export function createLabelsSelector(labels: { [key: string]: string; }): string {
return Object.entries(labels)
.map(([key, value]) => `${key}=${value}`)
.join(',');
}
Loading

0 comments on commit ae3bdab

Please sign in to comment.