mirror of
https://github.com/microsoft/vscode.git
synced 2026-09-13 22:12:07 +01:00
It seems like this event fires in Codespaces... my _guess_ is that Codespaces basically overwrites all the settings from settings sync which causes this event to fire. It's surprising to me that this event fires even though the value hasn't changed. I can't repro this with the test resolver, and I also cant have Codespaces use Code - OSS, so we'll just have to see how this goes. This _should_ do the trick though. Fixes https://github.com/microsoft/vscode/issues/223508
437 lines
16 KiB
TypeScript
437 lines
16 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
import * as vscode from 'vscode';
|
|
import TelemetryReporter from '@vscode/extension-telemetry';
|
|
import { Keychain } from './common/keychain';
|
|
import { GitHubServer, IGitHubServer } from './githubServer';
|
|
import { PromiseAdapter, arrayEquals, promiseFromEvent } from './common/utils';
|
|
import { ExperimentationTelemetry } from './common/experimentationService';
|
|
import { Log } from './common/logger';
|
|
import { crypto } from './node/crypto';
|
|
import { CANCELLATION_ERROR, TIMED_OUT_ERROR, USER_CANCELLATION_ERROR } from './common/errors';
|
|
|
|
interface SessionData {
|
|
id: string;
|
|
account?: {
|
|
label?: string;
|
|
displayName?: string;
|
|
id: string;
|
|
};
|
|
scopes: string[];
|
|
accessToken: string;
|
|
}
|
|
|
|
export enum AuthProviderType {
|
|
github = 'github',
|
|
githubEnterprise = 'github-enterprise'
|
|
}
|
|
|
|
export class UriEventHandler extends vscode.EventEmitter<vscode.Uri> implements vscode.UriHandler {
|
|
private readonly _pendingNonces = new Map<string, string[]>();
|
|
private readonly _codeExchangePromises = new Map<string, { promise: Promise<string>; cancel: vscode.EventEmitter<void> }>();
|
|
|
|
public handleUri(uri: vscode.Uri) {
|
|
this.fire(uri);
|
|
}
|
|
|
|
public async waitForCode(logger: Log, scopes: string, nonce: string, token: vscode.CancellationToken) {
|
|
const existingNonces = this._pendingNonces.get(scopes) || [];
|
|
this._pendingNonces.set(scopes, [...existingNonces, nonce]);
|
|
|
|
let codeExchangePromise = this._codeExchangePromises.get(scopes);
|
|
if (!codeExchangePromise) {
|
|
codeExchangePromise = promiseFromEvent(this.event, this.handleEvent(logger, scopes));
|
|
this._codeExchangePromises.set(scopes, codeExchangePromise);
|
|
}
|
|
|
|
try {
|
|
return await Promise.race([
|
|
codeExchangePromise.promise,
|
|
new Promise<string>((_, reject) => setTimeout(() => reject(TIMED_OUT_ERROR), 300_000)), // 5min timeout
|
|
promiseFromEvent<void, string>(token.onCancellationRequested, (_, __, reject) => { reject(USER_CANCELLATION_ERROR); }).promise
|
|
]);
|
|
} finally {
|
|
this._pendingNonces.delete(scopes);
|
|
codeExchangePromise?.cancel.fire();
|
|
this._codeExchangePromises.delete(scopes);
|
|
}
|
|
}
|
|
|
|
private handleEvent: (logger: Log, scopes: string) => PromiseAdapter<vscode.Uri, string> =
|
|
(logger: Log, scopes) => (uri, resolve, reject) => {
|
|
const query = new URLSearchParams(uri.query);
|
|
const code = query.get('code');
|
|
const nonce = query.get('nonce');
|
|
if (!code) {
|
|
reject(new Error('No code'));
|
|
return;
|
|
}
|
|
if (!nonce) {
|
|
reject(new Error('No nonce'));
|
|
return;
|
|
}
|
|
|
|
const acceptedNonces = this._pendingNonces.get(scopes) || [];
|
|
if (!acceptedNonces.includes(nonce)) {
|
|
// A common scenario of this happening is if you:
|
|
// 1. Trigger a sign in with one set of scopes
|
|
// 2. Before finishing 1, you trigger a sign in with a different set of scopes
|
|
// In this scenario we should just return and wait for the next UriHandler event
|
|
// to run as we are probably still waiting on the user to hit 'Continue'
|
|
logger.info('Nonce not found in accepted nonces. Skipping this execution...');
|
|
return;
|
|
}
|
|
|
|
resolve(code);
|
|
};
|
|
}
|
|
|
|
export class GitHubAuthenticationProvider implements vscode.AuthenticationProvider, vscode.Disposable {
|
|
private readonly _sessionChangeEmitter = new vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();
|
|
private readonly _logger: Log;
|
|
private readonly _githubServer: IGitHubServer;
|
|
private readonly _telemetryReporter: ExperimentationTelemetry;
|
|
private readonly _keychain: Keychain;
|
|
private readonly _accountsSeen = new Set<string>();
|
|
private readonly _disposable: vscode.Disposable | undefined;
|
|
private _supportsMultipleAccounts = false;
|
|
|
|
private _sessionsPromise: Promise<vscode.AuthenticationSession[]>;
|
|
|
|
constructor(
|
|
private readonly context: vscode.ExtensionContext,
|
|
uriHandler: UriEventHandler,
|
|
ghesUri?: vscode.Uri
|
|
) {
|
|
const { aiKey } = context.extension.packageJSON as { name: string; version: string; aiKey: string };
|
|
this._telemetryReporter = new ExperimentationTelemetry(context, new TelemetryReporter(aiKey));
|
|
|
|
const type = ghesUri ? AuthProviderType.githubEnterprise : AuthProviderType.github;
|
|
|
|
this._logger = new Log(type);
|
|
|
|
this._keychain = new Keychain(
|
|
this.context,
|
|
type === AuthProviderType.github
|
|
? `${type}.auth`
|
|
: `${ghesUri?.authority}${ghesUri?.path}.ghes.auth`,
|
|
this._logger);
|
|
|
|
this._githubServer = new GitHubServer(
|
|
this._logger,
|
|
this._telemetryReporter,
|
|
uriHandler,
|
|
context.extension.extensionKind,
|
|
ghesUri);
|
|
|
|
// Contains the current state of the sessions we have available.
|
|
this._sessionsPromise = this.readSessions().then((sessions) => {
|
|
// fire telemetry after a second to allow the workbench to focus on loading
|
|
setTimeout(() => sessions.forEach(s => this.afterSessionLoad(s)), 1000);
|
|
return sessions;
|
|
});
|
|
|
|
this._supportsMultipleAccounts = vscode.workspace.getConfiguration('github.experimental').get<boolean>('multipleAccounts', false);
|
|
|
|
this._disposable = vscode.Disposable.from(
|
|
this._telemetryReporter,
|
|
vscode.authentication.registerAuthenticationProvider(type, this._githubServer.friendlyName, this, { supportsMultipleAccounts: this._supportsMultipleAccounts }),
|
|
this.context.secrets.onDidChange(() => this.checkForUpdates()),
|
|
vscode.workspace.onDidChangeConfiguration(async e => {
|
|
if (e.affectsConfiguration('github.experimental.multipleAccounts')) {
|
|
const newValue = vscode.workspace.getConfiguration('github.experimental').get<boolean>('multipleAccounts', false);
|
|
if (newValue === this._supportsMultipleAccounts) {
|
|
return;
|
|
}
|
|
const result = await vscode.window.showInformationMessage(vscode.l10n.t('Please reload the window to apply the new setting.'), { modal: true }, vscode.l10n.t('Reload Window'));
|
|
if (result) {
|
|
vscode.commands.executeCommand('workbench.action.reloadWindow');
|
|
}
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
dispose() {
|
|
this._disposable?.dispose();
|
|
}
|
|
|
|
get onDidChangeSessions() {
|
|
return this._sessionChangeEmitter.event;
|
|
}
|
|
|
|
async getSessions(scopes: string[] | undefined, options?: vscode.AuthenticationProviderSessionOptions): Promise<vscode.AuthenticationSession[]> {
|
|
// For GitHub 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 accountFilteredSessions = options?.account
|
|
? sessions.filter(session => session.account.label === options.account?.label)
|
|
: sessions;
|
|
const finalSessions = sortedScopes.length
|
|
? accountFilteredSessions.filter(session => arrayEquals([...session.scopes].sort(), sortedScopes))
|
|
: accountFilteredSessions;
|
|
|
|
this._logger.info(`Got ${finalSessions.length} sessions for ${sortedScopes?.join(',') ?? 'all scopes'}...`);
|
|
return finalSessions;
|
|
}
|
|
|
|
private async afterSessionLoad(session: vscode.AuthenticationSession): Promise<void> {
|
|
// We only want to fire a telemetry if we haven't seen this account yet in this session.
|
|
if (!this._accountsSeen.has(session.account.id)) {
|
|
this._accountsSeen.add(session.account.id);
|
|
this._githubServer.sendAdditionalTelemetryInfo(session);
|
|
}
|
|
}
|
|
|
|
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: [] });
|
|
}
|
|
}
|
|
|
|
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 [];
|
|
}
|
|
|
|
// TODO: eventually remove this Set because we should only have one session per set of scopes.
|
|
const scopesSeen = new Set<string>();
|
|
const sessionPromises = sessionData.map(async (session: SessionData) => {
|
|
// For GitHub scope list, order doesn't matter so we immediately sort the scopes
|
|
const scopesStr = [...session.scopes].sort().join(' ');
|
|
if (!this._supportsMultipleAccounts && scopesSeen.has(scopesStr)) {
|
|
return undefined;
|
|
}
|
|
let userInfo: { id: string; accountName: string } | undefined;
|
|
if (!session.account) {
|
|
try {
|
|
userInfo = await this._githubServer.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}`);
|
|
scopesSeen.add(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>'
|
|
},
|
|
// we set this to session.scopes to maintain the original order of the scopes requested
|
|
// by the extension that called getSession()
|
|
scopes: session.scopes,
|
|
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[], options?: vscode.AuthenticationProviderSessionOptions): Promise<vscode.AuthenticationSession> {
|
|
try {
|
|
// For GitHub scope list, order doesn't matter so we use a sorted scope to determine
|
|
// if we've got a session already.
|
|
const sortedScopes = [...scopes].sort();
|
|
|
|
/* __GDPR__
|
|
"login" : {
|
|
"owner": "TylerLeonhardt",
|
|
"comment": "Used to determine how much usage the GitHub Auth Provider gets.",
|
|
"scopes": { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight", "comment": "Used to determine what scope combinations are being requested." }
|
|
}
|
|
*/
|
|
this._telemetryReporter?.sendTelemetryEvent('login', {
|
|
scopes: JSON.stringify(scopes),
|
|
});
|
|
|
|
const sessions = await this._sessionsPromise;
|
|
|
|
const forcedLogin = options?.account?.label;
|
|
const backupLogin = sessions[0]?.account.label;
|
|
this._logger.info(`Logging in with '${forcedLogin ? forcedLogin : 'any'}' account...`);
|
|
|
|
const scopeString = sortedScopes.join(' ');
|
|
const token = await this._githubServer.login(scopeString, forcedLogin ?? backupLogin);
|
|
const session = await this.tokenToSession(token, scopes);
|
|
|
|
// If an account was specified, we should ensure that the token we got back is for that account.
|
|
if (forcedLogin) {
|
|
if (session.account.label !== forcedLogin) {
|
|
const keepNewAccount = vscode.l10n.t('Keep {0}', session.account.label);
|
|
const tryAgain = vscode.l10n.t('Login with {0}', forcedLogin);
|
|
const result = await vscode.window.showWarningMessage(
|
|
vscode.l10n.t('Incorrect account detected'),
|
|
{ modal: true, detail: vscode.l10n.t('The chosen account, {0}, does not match the requested account, {1}.', session.account.label, forcedLogin) },
|
|
keepNewAccount,
|
|
tryAgain
|
|
);
|
|
if (result === tryAgain) {
|
|
return await this.createSession(scopes, {
|
|
...options,
|
|
// The id doesn't matter here, we just need to pass the label through
|
|
account: { id: forcedLogin, label: forcedLogin }
|
|
});
|
|
}
|
|
// Cancelled result
|
|
if (!result) {
|
|
throw new Error(CANCELLATION_ERROR);
|
|
}
|
|
// Keep result continues on
|
|
}
|
|
}
|
|
this.afterSessionLoad(session);
|
|
|
|
const sessionIndex = sessions.findIndex(
|
|
this._supportsMultipleAccounts
|
|
? s => s.account.id === session.account.id && arrayEquals([...s.scopes].sort(), sortedScopes)
|
|
: s => s.id === session.id || arrayEquals([...s.scopes].sort(), sortedScopes)
|
|
);
|
|
const removed = new Array<vscode.AuthenticationSession>();
|
|
if (sessionIndex > -1) {
|
|
removed.push(...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') {
|
|
/* __GDPR__
|
|
"loginCancelled" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users cancel the login flow." }
|
|
*/
|
|
this._telemetryReporter?.sendTelemetryEvent('loginCancelled');
|
|
throw e;
|
|
}
|
|
|
|
/* __GDPR__
|
|
"loginFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users run into an error login flow." }
|
|
*/
|
|
this._telemetryReporter?.sendTelemetryEvent('loginFailed');
|
|
|
|
vscode.window.showErrorMessage(vscode.l10n.t('Sign in failed: {0}', `${e}`));
|
|
this._logger.error(e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
private async tokenToSession(token: string, scopes: string[]): Promise<vscode.AuthenticationSession> {
|
|
const userInfo = await this._githubServer.getUserInfo(token);
|
|
return {
|
|
id: crypto.getRandomValues(new Uint32Array(2)).reduce((prev, curr) => prev += curr.toString(16), ''),
|
|
accessToken: token,
|
|
account: { label: userInfo.accountName, id: userInfo.id },
|
|
scopes
|
|
};
|
|
}
|
|
|
|
public async removeSession(id: string) {
|
|
try {
|
|
/* __GDPR__
|
|
"logout" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users log out of an account." }
|
|
*/
|
|
this._telemetryReporter?.sendTelemetryEvent('logout');
|
|
|
|
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);
|
|
await this._githubServer.logout(session);
|
|
|
|
this._sessionChangeEmitter.fire({ added: [], removed: [session], changed: [] });
|
|
} else {
|
|
this._logger.error('Session not found');
|
|
}
|
|
} catch (e) {
|
|
/* __GDPR__
|
|
"logoutFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often logging out of an account fails." }
|
|
*/
|
|
this._telemetryReporter?.sendTelemetryEvent('logoutFailed');
|
|
|
|
vscode.window.showErrorMessage(vscode.l10n.t('Sign out failed: {0}', `${e}`));
|
|
this._logger.error(e);
|
|
throw e;
|
|
}
|
|
}
|
|
}
|