mirror of
https://github.com/microsoft/vscode.git
synced 2026-08-06 05:14:41 +01:00
04bcb01ddf
So that all auth providers can take advantage of this logic. This basically will do a light enforcement that the account you signed in to matches the account that was requested (if it was specified). This is needed for finalization.
411 lines
15 KiB
TypeScript
411 lines
15 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 { 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;
|
|
|
|
// First we use the account specified in the options, otherwise we use the first account we have to seed auth.
|
|
const loginWith = options?.account?.label ?? sessions[0]?.account.label;
|
|
this._logger.info(`Logging in with '${loginWith ? loginWith : 'any'}' account...`);
|
|
|
|
const scopeString = sortedScopes.join(' ');
|
|
const token = await this._githubServer.login(scopeString, loginWith);
|
|
const session = await this.tokenToSession(token, scopes);
|
|
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;
|
|
}
|
|
}
|
|
}
|