Skip to content

Commit 0014a2e

Browse files
authored
Start migration due to keytar removal in the future (#15)
Some refactor
1 parent 1e96d98 commit 0014a2e

File tree

3 files changed

+313
-177
lines changed

3 files changed

+313
-177
lines changed

gitpod-web/src/authentication.ts

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Gitpod. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
import * as vscode from 'vscode';
6+
import type * as keytarType from 'keytar';
7+
import fetch from 'node-fetch';
8+
import { Disposable, GitpodExtensionContext } from 'gitpod-shared';
9+
import Keychain from './util/keychain';
10+
11+
type Keytar = {
12+
getPassword: typeof keytarType['getPassword'];
13+
setPassword: typeof keytarType['setPassword'];
14+
deletePassword: typeof keytarType['deletePassword'];
15+
};
16+
17+
interface SessionData {
18+
id: string;
19+
account?: {
20+
label?: string;
21+
displayName?: string;
22+
id: string;
23+
};
24+
scopes: string[];
25+
accessToken: string;
26+
}
27+
28+
interface UserInfo {
29+
id: string;
30+
accountName: string;
31+
}
32+
33+
export class GitpodAuthenticationProvider extends Disposable implements vscode.AuthenticationProvider {
34+
private _sessionChangeEmitter = this._register(new vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>());
35+
36+
private _keychain: Keychain;
37+
38+
private _sessionsPromise: Promise<vscode.AuthenticationSession[]>;
39+
40+
constructor(private context: GitpodExtensionContext) {
41+
super();
42+
43+
this._keychain = new Keychain(context, `gitpod.auth`, context.logger);
44+
45+
this._sessionsPromise = this.readSessions();
46+
47+
this._register(vscode.authentication.registerAuthenticationProvider('gitpod', 'Gitpod', this, { supportsMultipleAccounts: false }));
48+
}
49+
50+
get onDidChangeSessions() {
51+
return this._sessionChangeEmitter.event;
52+
}
53+
54+
private async resolveGitpodUser(): Promise<UserInfo> {
55+
const owner = await this.context.owner;
56+
return {
57+
id: owner.id,
58+
accountName: owner.name!
59+
};
60+
}
61+
62+
private async readSessions(): Promise<vscode.AuthenticationSession[]> {
63+
let sessionData: SessionData[];
64+
try {
65+
this.context.logger.info('Reading sessions from keychain...');
66+
let storedSessions = await this._keychain.getToken();
67+
if (!storedSessions) {
68+
// Fallback to old behavior
69+
this.context.logger.warn('Falling back to deprecated keytar logic');
70+
71+
const keytar: Keytar = require('keytar');
72+
storedSessions = await keytar.getPassword(`gitpod-code-gitpod.login`, 'account');
73+
if (!storedSessions) {
74+
return []
75+
}
76+
await keytar.deletePassword(`gitpod-code-gitpod.login`, 'account');
77+
}
78+
this.context.logger.info('Got stored sessions!');
79+
80+
try {
81+
sessionData = JSON.parse(storedSessions);
82+
} catch (e) {
83+
await this._keychain.deleteToken();
84+
throw e;
85+
}
86+
} catch (e) {
87+
this.context.logger.error(`Error reading token: ${e}`);
88+
return [];
89+
}
90+
91+
const sessionPromises = sessionData.map(async (session: SessionData) => {
92+
// For the Gitpod scope list, order doesn't matter so we immediately sort the scopes
93+
const sortedScopes = session.scopes.sort();
94+
const scopesStr = sortedScopes.join(' ');
95+
96+
let userInfo: UserInfo | undefined;
97+
try {
98+
userInfo = await this.resolveGitpodUser();
99+
this.context.logger.info(`Verified session with the following scopes: ${scopesStr}`);
100+
} catch (e) {
101+
// Remove sessions that return unauthorized response
102+
if (e.message.includes('Unexpected server response: 401')) {
103+
return undefined;
104+
}
105+
this.context.logger.error(`Error while verifying session with the following scopes: ${scopesStr}`, e);
106+
}
107+
108+
this.context.logger.trace(`Read the following session from the keychain with the following scopes: ${scopesStr}`);
109+
return {
110+
id: session.id,
111+
account: {
112+
label: session.account
113+
? session.account.label ?? session.account.displayName ?? '<unknown>'
114+
: userInfo?.accountName ?? '<unknown>',
115+
id: session.account?.id ?? userInfo?.id ?? '<unknown>'
116+
},
117+
scopes: sortedScopes,
118+
accessToken: session.accessToken
119+
};
120+
});
121+
122+
const verifiedSessions = (await Promise.allSettled(sessionPromises))
123+
.filter(p => p.status === 'fulfilled')
124+
.map(p => (p as PromiseFulfilledResult<vscode.AuthenticationSession | undefined>).value)
125+
.filter(<T>(p?: T): p is T => Boolean(p));
126+
127+
this.context.logger.info(`Got ${verifiedSessions.length} verified sessions.`);
128+
129+
return verifiedSessions;
130+
}
131+
132+
async getSessions(scopes?: string[]) {
133+
const sortedScopes = scopes?.slice().sort() || [];
134+
this.context.logger.info(`Getting sessions for ${sortedScopes.length ? sortedScopes.join(',') : 'all scopes'}...`);
135+
136+
const sessions = await this._sessionsPromise;
137+
138+
const finalSessions = sortedScopes.length
139+
? sessions.filter(session => sortedScopes.every(s => session.scopes.includes(s)))
140+
: sessions;
141+
142+
this.context.logger.info(`Got ${finalSessions.length} sessions for ${sortedScopes.join(',') || 'all scopes'}...`);
143+
return finalSessions;
144+
}
145+
146+
async createSession(_scopes: string[]): Promise<vscode.AuthenticationSession> {
147+
throw new Error('not supported');
148+
}
149+
150+
async removeSession() {
151+
throw new Error('not supported');
152+
}
153+
}
154+
155+
export class GithubAuthenticationProvider extends Disposable implements vscode.AuthenticationProvider {
156+
private _sessionChangeEmitter = this._register(new vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>());
157+
158+
private _sessionsPromise: Promise<vscode.AuthenticationSession[]>;
159+
160+
constructor(private context: GitpodExtensionContext) {
161+
super();
162+
163+
this._sessionsPromise = this.loginGitHub([]).then(s => [s]).catch(e => { context.logger.error('Failed at initial GitHub login:', e); return []; });
164+
165+
this._register(vscode.authentication.registerAuthenticationProvider('github', 'GitHub', this, { supportsMultipleAccounts: false }));
166+
}
167+
168+
get onDidChangeSessions() {
169+
return this._sessionChangeEmitter.event;
170+
}
171+
172+
async getSessions(scopes?: string[]) {
173+
const sortedScopes = scopes?.slice().sort() || [];
174+
this.context.logger.info(`Getting GitHub sessions for ${sortedScopes.length ? sortedScopes.join(',') : 'all scopes'}...`);
175+
176+
const sessions = await this._sessionsPromise;
177+
178+
const finalSessions = sortedScopes.length
179+
? sessions.filter(session => sortedScopes.every(s => session.scopes.includes(s)))
180+
: sessions;
181+
182+
this.context.logger.info(`Got ${finalSessions.length} GitHub sessions for ${sortedScopes.join(',') || 'all scopes'}...`);
183+
return finalSessions;
184+
}
185+
186+
private async resolveGitHubUser(accessToken: string): Promise<UserInfo> {
187+
const userResponse = await fetch('https://api.github.com/user', {
188+
headers: {
189+
Authorization: `token ${accessToken}`,
190+
'User-Agent': 'Gitpod-Code'
191+
}
192+
});
193+
if (!userResponse.ok) {
194+
throw new Error(`Getting GitHub account info failed: ${userResponse.statusText}`);
195+
}
196+
const user = await (userResponse.json() as Promise<{ id: string; login: string }>);
197+
return {
198+
id: user.id,
199+
accountName: user.login
200+
};
201+
}
202+
203+
private async loginGitHub(scopes: string[]): Promise<vscode.AuthenticationSession> {
204+
const resp = await this.context.supervisor.getToken(
205+
'git',
206+
'github.com',
207+
scopes
208+
);
209+
const userInfo = await this.resolveGitHubUser(resp.token);
210+
211+
const session = {
212+
id: 'github-session',
213+
account: {
214+
label: userInfo?.accountName ?? '<unknown>',
215+
id: userInfo?.id ?? '<unknown>'
216+
},
217+
scopes: resp.scopeList,
218+
accessToken: resp.token
219+
}
220+
221+
return session;
222+
}
223+
224+
async createSession(scopes: string[]) {
225+
try {
226+
const session = await this.loginGitHub(scopes.slice());
227+
228+
this._sessionsPromise = Promise.resolve([session]);
229+
230+
this._sessionChangeEmitter.fire({ added: [session], changed: [], removed: [] });
231+
232+
return session;
233+
} catch (e) {
234+
this.context.logger.error('GitHub sign in failed: ', e);
235+
throw e;
236+
}
237+
}
238+
239+
async removeSession(id: string) {
240+
try {
241+
this.context.logger.info(`Logging out of ${id}`);
242+
243+
const sessions = await this._sessionsPromise;
244+
const sessionIndex = sessions.findIndex(session => session.id === id);
245+
if (sessionIndex > -1) {
246+
const session = sessions[sessionIndex];
247+
sessions.splice(sessionIndex, 1);
248+
249+
this._sessionsPromise = Promise.resolve(sessions);
250+
251+
this._sessionChangeEmitter.fire({ added: [], removed: [session], changed: [] });
252+
} else {
253+
this.context.logger.error('Session not found');
254+
}
255+
} catch (e) {
256+
this.context.logger.error(e);
257+
throw e;
258+
}
259+
}
260+
}

0 commit comments

Comments
 (0)