Skip to content

Commit

Permalink
Add Device Activation 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 Jul 24, 2023
1 parent a73eee1 commit 1aa2f55
Show file tree
Hide file tree
Showing 11 changed files with 249 additions and 30 deletions.
1 change: 1 addition & 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,7 @@ export const GithubService = Symbol('GithubService');

export interface GithubService {
getToken(): Promise<string>;
updateToken(token: string): Promise<void>;
getUser(): Promise<GithubUser>;
getTokenScopes(token: string): Promise<string[]>;
}
6 changes: 5 additions & 1 deletion code/extensions/che-api/src/impl/github-service-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import * as path from 'path';

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

constructor(@inject(Symbol.for('AxiosInstance')) private readonly axiosInstance: AxiosInstance) {
const credentialsPath = path.resolve('/.git-credentials', 'credentials');
Expand All @@ -39,6 +39,10 @@ export class GithubServiceImpl implements GithubService {
return this.token!;
}

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

async getUser(): Promise<GithubUser> {
this.checkToken();
const result = await this.axiosInstance.get<GithubUser>('https://api.github.com/user', {
Expand Down
8 changes: 8 additions & 0 deletions code/extensions/che-github-authentication/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@
"label": "GitHub",
"id": "github"
}
],
"commands": [
{
"command": "github-authentication.device-code-flow.activation",
"title": "Device Activation",
"category": "GitHub",
"enablement": "github-authentication.device-code-flow.activation-enabled"
}
]
},
"main": "./out/extension.js",
Expand Down
78 changes: 78 additions & 0 deletions code/extensions/che-github-authentication/src/device-activation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**********************************************************************
* 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 */

import * as k8s from '@kubernetes/client-node';
import { inject, injectable } from 'inversify';
import * as vscode from 'vscode';
import { K8sHelper, base64Encode, createLabelsSelector } from './k8s-helper';

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 DeviceActivation {

@inject(K8sHelper)
private k8sHelper!: K8sHelper;

@inject(Symbol.for('GithubServiceInstance'))
private readonly githubService!: {
updateToken(token: string): Promise<void>
}

async initialize(context: vscode.ExtensionContext): Promise<void> {
// const isDeviceCodeFlowEnabled = context.workspaceState.get('github-authentication.device-code-flow.enabled');

vscode.commands.executeCommand('setContext', 'github-authentication.device-code-flow.activation-enabled', true);
context.subscriptions.push(
vscode.commands.registerCommand('github-authentication.device-code-flow.activation', async () => {
const token = await vscode.commands.executeCommand<string>('github-authentication.device-code-flow');
await this.updateToken(token);
}),
);
}

protected async getGithubSecret(): Promise<k8s.V1Secret | undefined> {
const gitCredentialSecrets = await this.k8sHelper.getSecret(GIT_CREDENTIALS_LABEL_SELECTOR);
if (gitCredentialSecrets.length === 0) {
return undefined
}

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

protected async updateToken(token: string): Promise<void> {
this.githubService.updateToken(token);

const gitCredentialSecret = await this.getGithubSecret();
if (!gitCredentialSecret) {
// todo - create a new secret
throw new Error('git credential secret is not found');
}

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

const newSecret = { ...gitCredentialSecret, data };
const name = gitCredentialSecret.metadata?.name || 'git-credentials-secret';
return this.k8sHelper.replaceNamespacedSecret(name, newSecret);
}
}

46 changes: 34 additions & 12 deletions code/extensions/che-github-authentication/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { AuthenticationSession } from 'vscode';
import { Container } from 'inversify';
import { K8sHelper } from './k8s-helper';
import { ErrorHandler } from './error-handler';
import { DeviceActivation } from './device-activation';

export async function activate(context: vscode.ExtensionContext): Promise<void> {
const extensionApi = vscode.extensions.getExtension('eclipse-che.api');
Expand All @@ -29,27 +30,28 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
container.bind(Symbol.for('DevfileServiceInstance')).toConstantValue(cheApi.getDevfileService());
container.bind(K8sHelper).toSelf().inSingletonScope();
container.bind(ErrorHandler).toSelf().inSingletonScope();
container.bind(DeviceActivation).toSelf().inSingletonScope();

const errorHandler = container.get(ErrorHandler);
const githubService = cheApi.getGithubService();
container.bind(Symbol.for('GithubServiceInstance')).toConstantValue(githubService);

const sessions: vscode.AuthenticationSession[] = context.workspaceState.get('sessions') || [];
const deviceActivation = container.get(DeviceActivation);
deviceActivation.initialize(context);

let sessions: vscode.AuthenticationSession[] = context.workspaceState.get('sessions') || [];
const onDidChangeSessions = new vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();
vscode.authentication.registerAuthenticationProvider('github', 'GitHub', {
onDidChangeSessions: onDidChangeSessions.event,
getSessions: async sessionScopes => {
const filteredSessions: AuthenticationSession[] = [];
getSessions: async (sessionScopes?: string[]) => {
let filteredSessions: AuthenticationSession[] = [];
for (const session of sessions) {
try {
const tokenScopes: string[] = await githubService.getTokenScopes(session.accessToken);
if (sessionScopes && sessionScopes.every(sessionScope => tokenScopes.some(
tokenScope =>
sessionScope === tokenScope
// compare partial scope with a full group scope e.g. "read:user" with "user".
|| sessionScope.includes(tokenScope + ':')
|| sessionScope.includes(':' + tokenScope)))) {
filteredSessions.push(session);
}
console.error('GET SESSION ', session.account.label, ' ', session.account.id);
const sortedScopes = sessionScopes?.sort() || [];
filteredSessions = sortedScopes.length
? sessions.filter(session => arrayEquals([...session.scopes].sort(), sortedScopes))
: sessions;
} catch (e) {
console.warn(e.message);
}
Expand All @@ -58,6 +60,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
},
createSession: async (scopes: string[]) => {
let token = '';
sessions = [];
try {
token = await githubService.getToken();
} catch (error) {
Expand Down Expand Up @@ -104,3 +107,22 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>

export function deactivate(): void {
}

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;
}

16 changes: 15 additions & 1 deletion code/extensions/che-github-authentication/src/k8s-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export class K8sHelper {
const coreV1API = this.getCoreApi();
const namespace = this.getDevWorkspaceNamespace();
if (!namespace) {
throw new Error('Can not get git credential secrets: DEVWORKSPACE_NAMESPACE env variable is not defined');
throw new Error('Can not get a secrets DEVWORKSPACE_NAMESPACE env variable is not defined');
}

try {
Expand All @@ -103,6 +103,16 @@ export class K8sHelper {
}
}

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);
}

protected getDevWorkspaceName(): string {
if (this.devWorkspaceName) {
return this.devWorkspaceName;
Expand Down Expand Up @@ -159,3 +169,7 @@ export function createLabelsSelector(labels: { [key: string]: string; }): string
.map(([key, value]) => `${key}=${value}`)
.join(',');
}

export function base64Encode(toEncode: string): string {
return Buffer.from(toEncode, 'binary').toString('base64');
}
100 changes: 100 additions & 0 deletions code/extensions/github-authentication/src/device-flow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**********************************************************************
* 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 */

import * as vscode from 'vscode';
import { Log } from './common/logger';
import { crypto } from './node/crypto';
import { ExtensionHost, GitHubTarget, getFlows } from './flows';
import { UriEventHandler, AuthProviderType } from './github';

interface FlowTriggerOptions {
scopes: string;
baseUri: vscode.Uri;
logger: Log;
redirectUri: vscode.Uri;
nonce: string;
callbackUri: vscode.Uri;
uriHandler: UriEventHandler;
enterpriseUri?: vscode.Uri;
}

interface Flow {
label: string;
trigger(options: FlowTriggerOptions): Promise<string>;
}

let logger: Log;
let deviceCodeFlow: Flow | undefined;
let flowOptions: FlowTriggerOptions;

export async function initialize(context: vscode.ExtensionContext): Promise<void> {
logger = new Log(AuthProviderType.github);
deviceCodeFlow = await getDeviceCodeFlow();

if (deviceCodeFlow) {
context.workspaceState.update('github-authentication.device-code-flow.enabled', 'true');

context.subscriptions.push(
vscode.commands.registerCommand('github-authentication.device-code-flow', (scopes: string) => {
return getToken(scopes);
}),
);
} else {
console.error('!!! Device Flow initialize !!!! NOT ENABLE ');
}
}

async function getFlowTriggerOptions(scopes?: string): Promise<FlowTriggerOptions> {
if (flowOptions) {
return scopes ? { ...flowOptions, scopes } : flowOptions;
}

const nonce: string = crypto.getRandomValues(new Uint32Array(2)).reduce((prev, curr) => prev += curr.toString(16), '');
const callbackUri = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://vscode.github-authentication/did-authenticate?nonce=${encodeURIComponent(nonce)}`));

flowOptions = {
logger,
nonce,
callbackUri,
scopes: scopes ? scopes : 'user:email',
uriHandler: new UriEventHandler(),
baseUri: vscode.Uri.parse('https://github.com/'),
redirectUri: vscode.Uri.parse('https://vscode.dev/redirect'),
}
return flowOptions;
}

async function getDeviceCodeFlow(): Promise<Flow | undefined> {
const flows = getFlows({ target: GitHubTarget.DotCom, extensionHost: ExtensionHost.Remote, isSupportedClient: true });
const filteredFlows = flows.filter(flow => {
const deviceCodeLabel = vscode.l10n.t('device code');
return flow.label === deviceCodeLabel;
});

return filteredFlows.length > 0 ? filteredFlows[0] : undefined;
}

export async function getToken(scopes: string): Promise<string> {
if (!deviceCodeFlow) {
throw new Error('Device Code Flow is not available');
}

const flowOptions = await getFlowTriggerOptions(scopes);
logger.info(`using ${deviceCodeFlow.label} flow with ${flowOptions.scopes} scopes to get token...`);

const token = await deviceCodeFlow.trigger(flowOptions);

logger.info(`the token was provided successfully!`);
return token;
}


7 changes: 7 additions & 0 deletions code/extensions/github-authentication/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
*--------------------------------------------------------------------------------------------*/

import * as vscode from 'vscode';
let enableExtension = false;
import { initialize } from './device-flow';
import { GitHubAuthenticationProvider, UriEventHandler } from './github';

function initGHES(context: vscode.ExtensionContext, uriHandler: UriEventHandler) {
Expand All @@ -27,6 +29,11 @@ function initGHES(context: vscode.ExtensionContext, uriHandler: UriEventHandler)
}

export function activate(context: vscode.ExtensionContext) {
if (!enableExtension) {
initialize(context);
return;
}

const uriHandler = new UriEventHandler();
context.subscriptions.push(uriHandler);
context.subscriptions.push(vscode.window.registerUriHandler(uriHandler));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import { IProductService } from 'vs/platform/product/common/productService';
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity';
import { IUserDataProfilesService } from 'vs/platform/userDataProfile/common/userDataProfile';
import { excludedExtensions } from 'vs/platform/extensionManagement/common/extensionsScannerService';

export type ExtensionVerificationStatus = boolean | string;
export type InstallableExtension = { readonly manifest: IExtensionManifest; extension: IGalleryExtension | URI; options: InstallOptions & InstallVSIXOptions };
Expand Down Expand Up @@ -257,9 +256,6 @@ export abstract class AbstractExtensionManagementService extends Disposable impl
const allDepsAndPackExtensionsToInstall = await this.getAllDepsAndPackExtensions(installExtensionTask.identifier, manifest, !!installExtensionTaskOptions.installOnlyNewlyAddedFromExtensionPack, !!installExtensionTaskOptions.installPreReleaseVersion, installExtensionTaskOptions.profileLocation);
const installed = await this.getInstalled(undefined, installExtensionTaskOptions.profileLocation);
for (const { gallery, manifest } of distinct(allDepsAndPackExtensionsToInstall, ({ gallery }) => gallery.identifier.id)) {
if (excludedExtensions.includes(gallery.name)) {
continue;
}
installExtensionHasDependents = installExtensionHasDependents || !!manifest.extensionDependencies?.some(id => areSameExtensions({ id }, installExtensionTask.identifier));
const key = getInstallExtensionTaskKey(gallery);
const existingInstallingExtension = this.installingExtensions.get(key);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -529,10 +529,6 @@ type NlsConfiguration = {
translations: Translations;
};

export const excludedExtensions = [
'github-authentication',
];

class ExtensionsScanner extends Disposable {

constructor(
Expand Down Expand Up @@ -563,9 +559,7 @@ class ExtensionsScanner extends Disposable {
return [];
}
const extensions = await Promise.all<IRelaxedScannedExtension | null>(
stat.children
.filter(c => !excludedExtensions.includes(c.name))
.map(async c => {
stat.children.map(async c => {
if (!c.isDirectory) {
return null;
}
Expand Down
Loading

0 comments on commit 1aa2f55

Please sign in to comment.