Path: blob/main/extensions/github-authentication/src/github.ts
3314 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});172173this._disposable = vscode.Disposable.from(174this._telemetryReporter,175vscode.authentication.registerAuthenticationProvider(176type,177this._githubServer.friendlyName,178this,179{180supportsMultipleAccounts: true,181supportedAuthorizationServers: [182ghesUri ?? vscode.Uri.parse('https://github.com/login/oauth')183]184}185),186this.context.secrets.onDidChange(() => this.checkForUpdates())187);188}189190dispose() {191this._disposable?.dispose();192}193194get onDidChangeSessions() {195return this._sessionChangeEmitter.event;196}197198async getSessions(scopes: string[] | undefined, options?: vscode.AuthenticationProviderSessionOptions): Promise<vscode.AuthenticationSession[]> {199// For GitHub scope list, order doesn't matter so we immediately sort the scopes200const sortedScopes = scopes?.sort() || [];201this._logger.info(`Getting sessions for ${sortedScopes.length ? sortedScopes.join(',') : 'all scopes'}...`);202const sessions = await this._sessionsPromise;203const accountFilteredSessions = options?.account204? sessions.filter(session => session.account.label === options.account?.label)205: sessions;206const finalSessions = sortedScopes.length207? accountFilteredSessions.filter(session => arrayEquals([...session.scopes].sort(), sortedScopes))208: accountFilteredSessions;209210this._logger.info(`Got ${finalSessions.length} sessions for ${sortedScopes?.join(',') ?? 'all scopes'}...`);211return finalSessions;212}213214private async afterSessionLoad(session: vscode.AuthenticationSession): Promise<void> {215// We only want to fire a telemetry if we haven't seen this account yet in this session.216if (!this._accountsSeen.has(session.account.id)) {217this._accountsSeen.add(session.account.id);218this._githubServer.sendAdditionalTelemetryInfo(session);219}220}221222private async checkForUpdates() {223const previousSessions = await this._sessionsPromise;224this._sessionsPromise = this.readSessions();225const storedSessions = await this._sessionsPromise;226227const added: vscode.AuthenticationSession[] = [];228const removed: vscode.AuthenticationSession[] = [];229230storedSessions.forEach(session => {231const matchesExisting = previousSessions.some(s => s.id === session.id);232// Another window added a session to the keychain, add it to our state as well233if (!matchesExisting) {234this._logger.info('Adding session found in keychain');235added.push(session);236}237});238239previousSessions.forEach(session => {240const matchesExisting = storedSessions.some(s => s.id === session.id);241// Another window has logged out, remove from our state242if (!matchesExisting) {243this._logger.info('Removing session no longer found in keychain');244removed.push(session);245}246});247248if (added.length || removed.length) {249this._sessionChangeEmitter.fire({ added, removed, changed: [] });250}251}252253private async readSessions(): Promise<vscode.AuthenticationSession[]> {254let sessionData: SessionData[];255try {256this._logger.info('Reading sessions from keychain...');257const storedSessions = await this._keychain.getToken();258if (!storedSessions) {259return [];260}261this._logger.info('Got stored sessions!');262263try {264sessionData = JSON.parse(storedSessions);265} catch (e) {266await this._keychain.deleteToken();267throw e;268}269} catch (e) {270this._logger.error(`Error reading token: ${e}`);271return [];272}273274// Unfortunately, we were using a number secretly for the account id for some time... this is due to a bad `any`.275// AuthenticationSession's account id is a string, so we need to detect when there is a number accountId and re-store276// the sessions to migrate away from the bad number usage.277// TODO@TylerLeonhardt: Remove this after we are confident that all users have migrated to the new id.278let seenNumberAccountId: boolean = false;279// TODO: eventually remove this Set because we should only have one session per set of scopes.280const scopesSeen = new Set<string>();281const sessionPromises = sessionData.map(async (session: SessionData): Promise<vscode.AuthenticationSession | undefined> => {282// For GitHub scope list, order doesn't matter so we immediately sort the scopes283const scopesStr = [...session.scopes].sort().join(' ');284let userInfo: { id: string; accountName: string } | undefined;285if (!session.account) {286try {287userInfo = await this._githubServer.getUserInfo(session.accessToken);288this._logger.info(`Verified session with the following scopes: ${scopesStr}`);289} catch (e) {290// Remove sessions that return unauthorized response291if (e.message === 'Unauthorized') {292return undefined;293}294}295}296297this._logger.trace(`Read the following session from the keychain with the following scopes: ${scopesStr}`);298scopesSeen.add(scopesStr);299300let accountId: string;301if (session.account?.id) {302if (typeof session.account.id === 'number') {303seenNumberAccountId = true;304}305accountId = `${session.account.id}`;306} else {307accountId = userInfo?.id ?? '<unknown>';308}309return {310id: session.id,311account: {312label: session.account313? session.account.label ?? session.account.displayName ?? '<unknown>'314: userInfo?.accountName ?? '<unknown>',315id: accountId316},317// we set this to session.scopes to maintain the original order of the scopes requested318// by the extension that called getSession()319scopes: session.scopes,320accessToken: session.accessToken321};322});323324const verifiedSessions = (await Promise.allSettled(sessionPromises))325.filter(p => p.status === 'fulfilled')326.map(p => (p as PromiseFulfilledResult<vscode.AuthenticationSession | undefined>).value)327.filter(<T>(p?: T): p is T => Boolean(p));328329this._logger.info(`Got ${verifiedSessions.length} verified sessions.`);330if (seenNumberAccountId || verifiedSessions.length !== sessionData.length) {331await this.storeSessions(verifiedSessions);332}333334return verifiedSessions;335}336337private async storeSessions(sessions: vscode.AuthenticationSession[]): Promise<void> {338this._logger.info(`Storing ${sessions.length} sessions...`);339this._sessionsPromise = Promise.resolve(sessions);340await this._keychain.setToken(JSON.stringify(sessions));341this._logger.info(`Stored ${sessions.length} sessions!`);342}343344public async createSession(scopes: string[], options?: GitHubAuthenticationProviderOptions): Promise<vscode.AuthenticationSession> {345try {346// For GitHub scope list, order doesn't matter so we use a sorted scope to determine347// if we've got a session already.348const sortedScopes = [...scopes].sort();349350/* __GDPR__351"login" : {352"owner": "TylerLeonhardt",353"comment": "Used to determine how much usage the GitHub Auth Provider gets.",354"scopes": { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight", "comment": "Used to determine what scope combinations are being requested." }355}356*/357this._telemetryReporter?.sendTelemetryEvent('login', {358scopes: JSON.stringify(scopes),359});360361if (options && !isGitHubAuthenticationProviderOptions(options)) {362throw new Error('Invalid options');363}364const sessions = await this._sessionsPromise;365const loginWith = options?.account?.label;366const signInProvider = options?.provider;367this._logger.info(`Logging in with${signInProvider ? ` ${signInProvider}, ` : ''} '${loginWith ? loginWith : 'any'}' account...`);368const scopeString = sortedScopes.join(' ');369const token = await this._githubServer.login(scopeString, signInProvider, options?.extraAuthorizeParameters, loginWith);370const session = await this.tokenToSession(token, scopes);371this.afterSessionLoad(session);372373const sessionIndex = sessions.findIndex(s => s.account.id === session.account.id && arrayEquals([...s.scopes].sort(), sortedScopes));374const removed = new Array<vscode.AuthenticationSession>();375if (sessionIndex > -1) {376removed.push(...sessions.splice(sessionIndex, 1, session));377} else {378sessions.push(session);379}380await this.storeSessions(sessions);381382this._sessionChangeEmitter.fire({ added: [session], removed, changed: [] });383384this._logger.info('Login success!');385386return session;387} catch (e) {388// If login was cancelled, do not notify user.389if (e === 'Cancelled' || e.message === 'Cancelled') {390/* __GDPR__391"loginCancelled" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users cancel the login flow." }392*/393this._telemetryReporter?.sendTelemetryEvent('loginCancelled');394throw e;395}396397/* __GDPR__398"loginFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users run into an error login flow." }399*/400this._telemetryReporter?.sendTelemetryEvent('loginFailed');401402vscode.window.showErrorMessage(vscode.l10n.t('Sign in failed: {0}', `${e}`));403this._logger.error(e);404throw e;405}406}407408private async tokenToSession(token: string, scopes: string[]): Promise<vscode.AuthenticationSession> {409const userInfo = await this._githubServer.getUserInfo(token);410return {411id: crypto.getRandomValues(new Uint32Array(2)).reduce((prev, curr) => prev += curr.toString(16), ''),412accessToken: token,413account: { label: userInfo.accountName, id: userInfo.id },414scopes415};416}417418public async removeSession(id: string) {419try {420/* __GDPR__421"logout" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users log out of an account." }422*/423this._telemetryReporter?.sendTelemetryEvent('logout');424425this._logger.info(`Logging out of ${id}`);426427const sessions = await this._sessionsPromise;428const sessionIndex = sessions.findIndex(session => session.id === id);429if (sessionIndex > -1) {430const session = sessions[sessionIndex];431sessions.splice(sessionIndex, 1);432433await this.storeSessions(sessions);434await this._githubServer.logout(session);435436this._sessionChangeEmitter.fire({ added: [], removed: [session], changed: [] });437} else {438this._logger.error('Session not found');439}440} catch (e) {441/* __GDPR__442"logoutFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often logging out of an account fails." }443*/444this._telemetryReporter?.sendTelemetryEvent('logoutFailed');445446vscode.window.showErrorMessage(vscode.l10n.t('Sign out failed: {0}', `${e}`));447this._logger.error(e);448throw e;449}450}451}452453454