Path: blob/main/extensions/github-authentication/src/github.ts
5240 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as vscode from 'vscode';6import TelemetryReporter from '@vscode/extension-telemetry';7import { Keychain } from './common/keychain';8import { GitHubServer, IGitHubServer } from './githubServer';9import { PromiseAdapter, arrayEquals, promiseFromEvent } from './common/utils';10import { ExperimentationTelemetry } from './common/experimentationService';11import { Log } from './common/logger';12import { crypto } from './node/crypto';13import { TIMED_OUT_ERROR, USER_CANCELLATION_ERROR } from './common/errors';14import { GitHubSocialSignInProvider, isSocialSignInProvider } from './flows';1516interface SessionData {17id: string;18account?: {19label?: string;20displayName?: string;21// Unfortunately, for some time the id was a number, so we need to support both.22// This can be removed once we are confident that all users have migrated to the new id.23id: string | number;24};25scopes: string[];26accessToken: string;27}2829export enum AuthProviderType {30github = 'github',31githubEnterprise = 'github-enterprise'32}3334interface GitHubAuthenticationProviderOptions extends vscode.AuthenticationProviderSessionOptions {35/**36* This is specific to GitHub and is used to determine which social sign-in provider to use.37* If not provided, the default (GitHub) is used which shows all options.38*39* Example: If you specify Google, then the sign-in flow will skip the initial page that asks you40* to choose how you want to sign in and will directly take you to the Google sign-in page.41*42* This allows us to show "Continue with Google" buttons in the product, rather than always43* leaving it up to the user to choose the social sign-in provider on the sign-in page.44*/45readonly provider?: GitHubSocialSignInProvider;46readonly extraAuthorizeParameters?: Record<string, string>;47}4849function isGitHubAuthenticationProviderOptions(object: any): object is GitHubAuthenticationProviderOptions {50if (!object || typeof object !== 'object') {51throw new Error('Options are not an object');52}53if (object.provider !== undefined && !isSocialSignInProvider(object.provider)) {54throw new Error(`Provider is invalid: ${object.provider}`);55}56if (object.extraAuthorizeParameters !== undefined) {57if (!object.extraAuthorizeParameters || typeof object.extraAuthorizeParameters !== 'object') {58throw new Error('Extra parameters must be a record of string keys and string values.');59}60for (const [key, value] of Object.entries(object.extraAuthorizeParameters)) {61if (typeof key !== 'string' || typeof value !== 'string') {62throw new Error('Extra parameters must be a record of string keys and string values.');63}64}65}66return true;67}6869export class UriEventHandler extends vscode.EventEmitter<vscode.Uri> implements vscode.UriHandler {70private readonly _pendingNonces = new Map<string, string[]>();71private readonly _codeExchangePromises = new Map<string, { promise: Promise<string>; cancel: vscode.EventEmitter<void> }>();7273public handleUri(uri: vscode.Uri) {74this.fire(uri);75}7677public async waitForCode(logger: Log, scopes: string, nonce: string, token: vscode.CancellationToken) {78const existingNonces = this._pendingNonces.get(scopes) || [];79this._pendingNonces.set(scopes, [...existingNonces, nonce]);8081let codeExchangePromise = this._codeExchangePromises.get(scopes);82if (!codeExchangePromise) {83codeExchangePromise = promiseFromEvent(this.event, this.handleEvent(logger, scopes));84this._codeExchangePromises.set(scopes, codeExchangePromise);85}8687try {88return await Promise.race([89codeExchangePromise.promise,90new Promise<string>((_, reject) => setTimeout(() => reject(TIMED_OUT_ERROR), 300_000)), // 5min timeout91promiseFromEvent<void, string>(token.onCancellationRequested, (_, __, reject) => { reject(USER_CANCELLATION_ERROR); }).promise92]);93} finally {94this._pendingNonces.delete(scopes);95codeExchangePromise?.cancel.fire();96this._codeExchangePromises.delete(scopes);97}98}99100private handleEvent: (logger: Log, scopes: string) => PromiseAdapter<vscode.Uri, string> =101(logger: Log, scopes) => (uri, resolve, reject) => {102const query = new URLSearchParams(uri.query);103const code = query.get('code');104const nonce = query.get('nonce');105if (!code) {106reject(new Error('No code'));107return;108}109if (!nonce) {110reject(new Error('No nonce'));111return;112}113114const acceptedNonces = this._pendingNonces.get(scopes) || [];115if (!acceptedNonces.includes(nonce)) {116// A common scenario of this happening is if you:117// 1. Trigger a sign in with one set of scopes118// 2. Before finishing 1, you trigger a sign in with a different set of scopes119// In this scenario we should just return and wait for the next UriHandler event120// to run as we are probably still waiting on the user to hit 'Continue'121logger.info('Nonce not found in accepted nonces. Skipping this execution...');122return;123}124125resolve(code);126};127}128129export class GitHubAuthenticationProvider implements vscode.AuthenticationProvider, vscode.Disposable {130private readonly _sessionChangeEmitter = new vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();131private readonly _logger: Log;132private readonly _githubServer: IGitHubServer;133private readonly _telemetryReporter: ExperimentationTelemetry;134private readonly _keychain: Keychain;135private readonly _accountsSeen = new Set<string>();136private readonly _disposable: vscode.Disposable | undefined;137138private _sessionsPromise: Promise<vscode.AuthenticationSession[]>;139140constructor(141private readonly context: vscode.ExtensionContext,142uriHandler: UriEventHandler,143ghesUri?: vscode.Uri144) {145const { aiKey } = context.extension.packageJSON as { name: string; version: string; aiKey: string };146this._telemetryReporter = new ExperimentationTelemetry(context, new TelemetryReporter(aiKey));147148const type = ghesUri ? AuthProviderType.githubEnterprise : AuthProviderType.github;149150this._logger = new Log(type);151152this._keychain = new Keychain(153this.context,154type === AuthProviderType.github155? `${type}.auth`156: `${ghesUri?.authority}${ghesUri?.path}.ghes.auth`,157this._logger);158159this._githubServer = new GitHubServer(160this._logger,161this._telemetryReporter,162uriHandler,163context.extension.extensionKind,164ghesUri);165166// Contains the current state of the sessions we have available.167this._sessionsPromise = this.readSessions().then((sessions) => {168// fire telemetry after a second to allow the workbench to focus on loading169setTimeout(() => sessions.forEach(s => this.afterSessionLoad(s)), 1000);170return sessions;171});172173const supportedAuthorizationServers = ghesUri174? [vscode.Uri.joinPath(ghesUri, '/login/oauth')]175: [vscode.Uri.parse('https://github.com/login/oauth')];176this._disposable = vscode.Disposable.from(177this._telemetryReporter,178vscode.authentication.registerAuthenticationProvider(179type,180this._githubServer.friendlyName,181this,182{183supportsMultipleAccounts: true,184supportedAuthorizationServers185}186),187this.context.secrets.onDidChange(() => this.checkForUpdates())188);189}190191dispose() {192this._disposable?.dispose();193}194195get onDidChangeSessions() {196return this._sessionChangeEmitter.event;197}198199async getSessions(scopes: string[] | undefined, options?: vscode.AuthenticationProviderSessionOptions): Promise<vscode.AuthenticationSession[]> {200// For GitHub scope list, order doesn't matter so we immediately sort the scopes201const sortedScopes = scopes?.sort() || [];202this._logger.info(`Getting sessions for ${sortedScopes.length ? sortedScopes.join(',') : 'all scopes'}...`);203const sessions = await this._sessionsPromise;204const accountFilteredSessions = options?.account205? sessions.filter(session => session.account.label === options.account?.label)206: sessions;207const finalSessions = sortedScopes.length208? accountFilteredSessions.filter(session => arrayEquals([...session.scopes].sort(), sortedScopes))209: accountFilteredSessions;210211this._logger.info(`Got ${finalSessions.length} sessions for ${sortedScopes?.join(',') ?? 'all scopes'}...`);212return finalSessions;213}214215private async afterSessionLoad(session: vscode.AuthenticationSession): Promise<void> {216// We only want to fire a telemetry if we haven't seen this account yet in this session.217if (!this._accountsSeen.has(session.account.id)) {218this._accountsSeen.add(session.account.id);219this._githubServer.sendAdditionalTelemetryInfo(session);220}221}222223private async checkForUpdates() {224const previousSessions = await this._sessionsPromise;225this._sessionsPromise = this.readSessions();226const storedSessions = await this._sessionsPromise;227228const added: vscode.AuthenticationSession[] = [];229const removed: vscode.AuthenticationSession[] = [];230231storedSessions.forEach(session => {232const matchesExisting = previousSessions.some(s => s.id === session.id);233// Another window added a session to the keychain, add it to our state as well234if (!matchesExisting) {235this._logger.info('Adding session found in keychain');236added.push(session);237}238});239240previousSessions.forEach(session => {241const matchesExisting = storedSessions.some(s => s.id === session.id);242// Another window has logged out, remove from our state243if (!matchesExisting) {244this._logger.info('Removing session no longer found in keychain');245removed.push(session);246}247});248249if (added.length || removed.length) {250this._sessionChangeEmitter.fire({ added, removed, changed: [] });251}252}253254private async readSessions(): Promise<vscode.AuthenticationSession[]> {255let sessionData: SessionData[];256try {257this._logger.info('Reading sessions from keychain...');258const storedSessions = await this._keychain.getToken();259if (!storedSessions) {260return [];261}262this._logger.info('Got stored sessions!');263264try {265sessionData = JSON.parse(storedSessions);266} catch (e) {267await this._keychain.deleteToken();268throw e;269}270} catch (e) {271this._logger.error(`Error reading token: ${e}`);272return [];273}274275// Unfortunately, we were using a number secretly for the account id for some time... this is due to a bad `any`.276// AuthenticationSession's account id is a string, so we need to detect when there is a number accountId and re-store277// the sessions to migrate away from the bad number usage.278// TODO@TylerLeonhardt: Remove this after we are confident that all users have migrated to the new id.279let seenNumberAccountId: boolean = false;280// TODO: eventually remove this Set because we should only have one session per set of scopes.281const scopesSeen = new Set<string>();282const sessionPromises = sessionData.map(async (session: SessionData): Promise<vscode.AuthenticationSession | undefined> => {283// For GitHub scope list, order doesn't matter so we immediately sort the scopes284const scopesStr = [...session.scopes].sort().join(' ');285let userInfo: { id: string; accountName: string } | undefined;286if (!session.account) {287try {288userInfo = await this._githubServer.getUserInfo(session.accessToken);289this._logger.info(`Verified session with the following scopes: ${scopesStr}`);290} catch (e) {291// Remove sessions that return unauthorized response292if (e.message === 'Unauthorized') {293return undefined;294}295}296}297298this._logger.trace(`Read the following session from the keychain with the following scopes: ${scopesStr}`);299scopesSeen.add(scopesStr);300301let accountId: string;302if (session.account?.id) {303if (typeof session.account.id === 'number') {304seenNumberAccountId = true;305}306accountId = `${session.account.id}`;307} else {308accountId = userInfo?.id ?? '<unknown>';309}310return {311id: session.id,312account: {313label: session.account314? session.account.label ?? session.account.displayName ?? '<unknown>'315: userInfo?.accountName ?? '<unknown>',316id: accountId317},318// we set this to session.scopes to maintain the original order of the scopes requested319// by the extension that called getSession()320scopes: session.scopes,321accessToken: session.accessToken322};323});324325const verifiedSessions = (await Promise.allSettled(sessionPromises))326.filter(p => p.status === 'fulfilled')327.map(p => (p as PromiseFulfilledResult<vscode.AuthenticationSession | undefined>).value)328.filter(<T>(p?: T): p is T => Boolean(p));329330this._logger.info(`Got ${verifiedSessions.length} verified sessions.`);331if (seenNumberAccountId || verifiedSessions.length !== sessionData.length) {332await this.storeSessions(verifiedSessions);333}334335return verifiedSessions;336}337338private async storeSessions(sessions: vscode.AuthenticationSession[]): Promise<void> {339this._logger.info(`Storing ${sessions.length} sessions...`);340this._sessionsPromise = Promise.resolve(sessions);341await this._keychain.setToken(JSON.stringify(sessions));342this._logger.info(`Stored ${sessions.length} sessions!`);343}344345public async createSession(scopes: string[], options?: GitHubAuthenticationProviderOptions): Promise<vscode.AuthenticationSession> {346try {347// For GitHub scope list, order doesn't matter so we use a sorted scope to determine348// if we've got a session already.349const sortedScopes = [...scopes].sort();350351/* __GDPR__352"login" : {353"owner": "TylerLeonhardt",354"comment": "Used to determine how much usage the GitHub Auth Provider gets.",355"scopes": { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight", "comment": "Used to determine what scope combinations are being requested." }356}357*/358this._telemetryReporter?.sendTelemetryEvent('login', {359scopes: JSON.stringify(scopes),360});361362if (options && !isGitHubAuthenticationProviderOptions(options)) {363throw new Error('Invalid options');364}365const sessions = await this._sessionsPromise;366const loginWith = options?.account?.label;367const signInProvider = options?.provider;368this._logger.info(`Logging in with${signInProvider ? ` ${signInProvider}, ` : ''} '${loginWith ? loginWith : 'any'}' account...`);369const scopeString = sortedScopes.join(' ');370const token = await this._githubServer.login(scopeString, signInProvider, options?.extraAuthorizeParameters, loginWith);371const session = await this.tokenToSession(token, scopes);372this.afterSessionLoad(session);373374const sessionIndex = sessions.findIndex(s => s.account.id === session.account.id && arrayEquals([...s.scopes].sort(), sortedScopes));375const removed = new Array<vscode.AuthenticationSession>();376if (sessionIndex > -1) {377removed.push(...sessions.splice(sessionIndex, 1, session));378} else {379sessions.push(session);380}381await this.storeSessions(sessions);382383this._sessionChangeEmitter.fire({ added: [session], removed, changed: [] });384385this._logger.info('Login success!');386387return session;388} catch (e) {389// If login was cancelled, do not notify user.390if (e === 'Cancelled' || e.message === 'Cancelled') {391/* __GDPR__392"loginCancelled" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users cancel the login flow." }393*/394this._telemetryReporter?.sendTelemetryEvent('loginCancelled');395throw e;396}397398/* __GDPR__399"loginFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users run into an error login flow." }400*/401this._telemetryReporter?.sendTelemetryEvent('loginFailed');402403vscode.window.showErrorMessage(vscode.l10n.t('Sign in failed: {0}', `${e}`));404this._logger.error(e);405throw e;406}407}408409private async tokenToSession(token: string, scopes: string[]): Promise<vscode.AuthenticationSession> {410const userInfo = await this._githubServer.getUserInfo(token);411return {412id: crypto.getRandomValues(new Uint32Array(2)).reduce((prev, curr) => prev += curr.toString(16), ''),413accessToken: token,414account: { label: userInfo.accountName, id: userInfo.id },415scopes416};417}418419public async removeSession(id: string) {420try {421/* __GDPR__422"logout" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users log out of an account." }423*/424this._telemetryReporter?.sendTelemetryEvent('logout');425426this._logger.info(`Logging out of ${id}`);427428const sessions = await this._sessionsPromise;429const sessionIndex = sessions.findIndex(session => session.id === id);430if (sessionIndex > -1) {431const session = sessions[sessionIndex];432sessions.splice(sessionIndex, 1);433434await this.storeSessions(sessions);435await this._githubServer.logout(session);436437this._sessionChangeEmitter.fire({ added: [], removed: [session], changed: [] });438} else {439this._logger.error('Session not found');440}441} catch (e) {442/* __GDPR__443"logoutFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often logging out of an account fails." }444*/445this._telemetryReporter?.sendTelemetryEvent('logoutFailed');446447vscode.window.showErrorMessage(vscode.l10n.t('Sign out failed: {0}', `${e}`));448this._logger.error(e);449throw e;450}451}452}453454455