Skip to content

Commit

Permalink
Implement gitpod auth and settings sync
Browse files Browse the repository at this point in the history
  • Loading branch information
filiptronicek authored and jeanp413 committed Apr 13, 2022
1 parent 93582a3 commit ccd91eb
Show file tree
Hide file tree
Showing 13 changed files with 1,442 additions and 446 deletions.
41 changes: 39 additions & 2 deletions extensions/gitpod/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,39 @@
],
"activationEvents": [
"*",
"onCommand:gitpod.api.autoTunnel"
"onCommand:gitpod.api.autoTunnel",
"onAuthenticationRequest:gitpod"
],
"contributes": {
"authentication": [
{
"label": "Gitpod",
"id": "gitpod"
}
],
"configuration": {
"title": "Settings sync",
"properties": {
"configurationSync.store": {
"type": "object"
}
}
},
"commands": [
{
"command": "gitpod.auth.remove",
"category": "Gitpod",
"enablement": "gitpod.addedSyncProvider",
"title": "Turn off Settings Sync"
},
{
"command": "gitpod.auth.add",
"category": "Gitpod",
"enablement": "!gitpod.addedSyncProvider",
"title": "Turn on Settings Sync"
}
]
},
"main": "./out/extension.js",
"scripts": {
"compile": "gulp compile-extension:gitpod",
Expand All @@ -40,12 +71,18 @@
"devDependencies": {
"@types/node": "16.x",
"@types/node-fetch": "^2.5.12",
"@types/tmp": "^0.2.1"
"@types/uuid": "8.0.0",
"@types/tmp": "^0.2.1",
"@types/crypto-js": "4.1.1",
"@types/ws": "^7.2.6"
},
"dependencies": {
"@gitpod/gitpod-protocol": "main",
"@gitpod/local-app-api-grpcweb": "main",
"@improbable-eng/grpc-web-node-http-transport": "^0.14.0",
"node-fetch": "2.6.7",
"pkce-challenge": "^3.0.0",
"uuid": "8.1.0",
"tmp": "^0.2.1",
"vscode-nls": "^5.0.0"
},
Expand Down
269 changes: 269 additions & 0 deletions extensions/gitpod/src/authentication.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Gitpod. All rights reserved.
*--------------------------------------------------------------------------------------------*/

import * as vscode from 'vscode';
import { v4 as uuid } from 'uuid';
import { Keychain } from './common/keychain';
import { GitpodServer, IGitpodServer } from './gitpodServer';
import { Log } from './common/logger';
import { unauthorizedErr, withServerApi } from './internalApi';
import cryptoSha256 from 'crypto-js/sha256';
import cryptoEncHex from 'crypto-js/enc-hex';
import { arrayEquals } from './common/utils';
import { Disposable } from './common/dispose';

interface SessionData {
id: string;
account?: {
label?: string;
displayName?: string;
id: string;
};
scopes: string[];
accessToken: string;
}

export class GitpodAuthenticationProvider extends Disposable implements vscode.AuthenticationProvider {
private _sessionChangeEmitter = new vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();
private _logger: Log;
private _gitpodServer: IGitpodServer;
private _keychain: Keychain;

private _sessionsPromise: Promise<vscode.AuthenticationSession[]>;

constructor(private readonly context: vscode.ExtensionContext, public logger: Log) {
super();
//todo: make sure to only proceed if context.extension.extensionKind === vscode.ExtensionKind.Workspace

this._logger = logger;
this._gitpodServer = new GitpodServer(this._logger);
this._keychain = new Keychain(this.context, `gitpod.auth`, this._logger);

// Contains the current state of the sessions we have available.
this._sessionsPromise = this.readSessions();

this._register(this._gitpodServer);
this._register(vscode.authentication.registerAuthenticationProvider('gitpod', 'Gitpod', this, { supportsMultipleAccounts: false }));
this._register(this.context.secrets.onDidChange(() => this.checkForUpdates()));
}

get onDidChangeSessions() {
return this._sessionChangeEmitter.event;
}

async getSessions(scopes?: string[]): Promise<vscode.AuthenticationSession[]> {
// For the Gitpod scope list, order doesn't matter so we immediately sort the scopes
const sortedScopes = scopes?.sort() || [];
this._logger.info(`Getting sessions for ${sortedScopes.length ? sortedScopes.join(',') : 'all scopes'}...`);
const sessions = await this._sessionsPromise;
const finalSessions = sortedScopes.length
? sessions.filter(session => arrayEquals([...session.scopes].sort(), sortedScopes))
: sessions;

this._logger.info(`Got ${finalSessions.length} sessions for ${sortedScopes?.join(',') ?? 'all scopes'}...`);
return finalSessions;
}

private async checkForUpdates() {
const previousSessions = await this._sessionsPromise;
this._sessionsPromise = this.readSessions();
const storedSessions = await this._sessionsPromise;

const added: vscode.AuthenticationSession[] = [];
const removed: vscode.AuthenticationSession[] = [];

storedSessions.forEach(session => {
const matchesExisting = previousSessions.some(s => s.id === session.id);
// Another window added a session to the keychain, add it to our state as well
if (!matchesExisting) {
this._logger.info('Adding session found in keychain');
added.push(session);
}
});

previousSessions.forEach(session => {
const matchesExisting = storedSessions.some(s => s.id === session.id);
// Another window has logged out, remove from our state
if (!matchesExisting) {
this._logger.info('Removing session no longer found in keychain');
removed.push(session);
}
});

if (added.length || removed.length) {
this._sessionChangeEmitter.fire({ added, removed, changed: [] });
}
}

/**
* Validates a given session via the server API. Also checks if all scopes match the stored value.
*/
private async validateSession(session: SessionData): Promise<boolean> {
try {
const hash = cryptoSha256(session.accessToken).toString(cryptoEncHex);
const tokenScopes = new Set(await withServerApi(session.accessToken, service => service.server.getGitpodTokenScopes(hash), this._logger));
for (const scope of session.scopes) {
if (!tokenScopes.has(scope)) {
return false;
}
}
return true;
} catch (e) {
if (e.message !== unauthorizedErr) {
this._logger.error(`gitpod: invalid session: ${e}`);
}
return false;
}
}

private async readSessions(): Promise<vscode.AuthenticationSession[]> {
let sessionData: SessionData[];
try {
this._logger.info('Reading sessions from keychain...');
const storedSessions = await this._keychain.getToken();
if (!storedSessions) {
return [];
}
this._logger.info('Got stored sessions!');

try {
sessionData = JSON.parse(storedSessions);
} catch (e) {
await this._keychain.deleteToken();
throw e;
}
} catch (e) {
this._logger.error(`Error reading token: ${e}`);
return [];
}

const sessionPromises = sessionData.map(async (session: SessionData) => {
// For the Gitpod scope list, order doesn't matter so we immediately sort the scopes
const sortedScopes = session.scopes.sort();
const scopesStr = sortedScopes.join(' ');

let userInfo: { id: string; accountName: string } | undefined;
if (!session.account) {

if (!(await this.validateSession(session))) {
return undefined;
}

try {
userInfo = await this._gitpodServer.getUserInfo(session.accessToken);
this._logger.info(`Verified session with the following scopes: ${scopesStr}`);
} catch (e) {
// Remove sessions that return unauthorized response
if (e.message === 'Unauthorized') {
return undefined;
}
}
}

this._logger.trace(`Read the following session from the keychain with the following scopes: ${scopesStr}`);
return {
id: session.id,
account: {
label: session.account
? session.account.label ?? session.account.displayName ?? '<unknown>'
: userInfo?.accountName ?? '<unknown>',
id: session.account?.id ?? userInfo?.id ?? '<unknown>'
},
scopes: sortedScopes,
accessToken: session.accessToken
};
});

const verifiedSessions = (await Promise.allSettled(sessionPromises))
.filter(p => p.status === 'fulfilled')
.map(p => (p as PromiseFulfilledResult<vscode.AuthenticationSession | undefined>).value)
.filter(<T>(p?: T): p is T => Boolean(p));

this._logger.info(`Got ${verifiedSessions.length} verified sessions.`);
if (verifiedSessions.length !== sessionData.length) {
await this.storeSessions(verifiedSessions);
}

return verifiedSessions;
}

private async storeSessions(sessions: vscode.AuthenticationSession[]): Promise<void> {
this._logger.info(`Storing ${sessions.length} sessions...`);
this._sessionsPromise = Promise.resolve(sessions);
await this._keychain.setToken(JSON.stringify(sessions));
this._logger.info(`Stored ${sessions.length} sessions!`);
}

public async createSession(scopes: string[]): Promise<vscode.AuthenticationSession> {
try {
// For the Gitpod scope list, order doesn't matter so we immediately sort the scopes
const sortedScopes = scopes.sort();

const scopeString = sortedScopes.join(' ');
const token = await this._gitpodServer.login(scopeString);
const session = await this.tokenToSession(token, sortedScopes);

const sessions = await this._sessionsPromise;
const sessionIndex = sessions.findIndex(s => s.id === session.id || arrayEquals([...s.scopes].sort(), sortedScopes));
if (sessionIndex > -1) {
sessions.splice(sessionIndex, 1, session);
} else {
sessions.push(session);
}
await this.storeSessions(sessions);

this._sessionChangeEmitter.fire({ added: [session], removed: [], changed: [] });

this._logger.info('Login success!');

return session;
} catch (e) {
// If login was cancelled, do not notify user.
if (e === 'Cancelled' || e.message === 'Cancelled') {
throw e;
}

vscode.window.showErrorMessage(`Sign in failed: ${e}`);
this._logger.error(e);
throw e;
}
}

private async tokenToSession(token: string, scopes: string[]): Promise<vscode.AuthenticationSession> {
const userInfo = await this._gitpodServer.getUserInfo(token);
return {
id: uuid(),
accessToken: token,
account: { label: userInfo.accountName, id: userInfo.id },
scopes
};
}

public async removeSession(id: string) {
try {
this._logger.info(`Logging out of ${id}`);

const sessions = await this._sessionsPromise;
const sessionIndex = sessions.findIndex(session => session.id === id);
if (sessionIndex > -1) {
const session = sessions[sessionIndex];
sessions.splice(sessionIndex, 1);

await this.storeSessions(sessions);

this._sessionChangeEmitter.fire({ added: [], removed: [session], changed: [] });
} else {
this._logger.error('Session not found');
}
} catch (e) {
vscode.window.showErrorMessage(`Sign out failed: ${e}`);
this._logger.error(e);
throw e;
}
}

public handleUri(uri: vscode.Uri) {
this._gitpodServer.hadleUri(uri);
}
}
41 changes: 41 additions & 0 deletions extensions/gitpod/src/common/dispose.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Gitpod. All rights reserved.
*--------------------------------------------------------------------------------------------*/

import * as vscode from 'vscode';

export function disposeAll(disposables: vscode.Disposable[]): void {
while (disposables.length) {
const item = disposables.pop();
if (item) {
item.dispose();
}
}
}

export abstract class Disposable {
private _isDisposed = false;

protected _disposables: vscode.Disposable[] = [];

public dispose(): any {
if (this._isDisposed) {
return;
}
this._isDisposed = true;
disposeAll(this._disposables);
}

protected _register<T extends vscode.Disposable>(value: T): T {
if (this._isDisposed) {
value.dispose();
} else {
this._disposables.push(value);
}
return value;
}

protected get isDisposed(): boolean {
return this._isDisposed;
}
}
Loading

0 comments on commit ccd91eb

Please sign in to comment.