diff --git a/extensions/github-authentication/src/githubServer.ts b/extensions/github-authentication/src/githubServer.ts index f1c27967fdd..981d38b46aa 100644 --- a/extensions/github-authentication/src/githubServer.ts +++ b/extensions/github-authentication/src/githubServer.ts @@ -25,36 +25,7 @@ export const uriHandler = new UriEventHandler; const onDidManuallyProvideToken = new vscode.EventEmitter(); -const exchangeCodeForToken: (state: string) => PromiseAdapter = - (state) => async (uri, resolve, reject) => { - Logger.info('Exchanging code for token...'); - const query = parseQuery(uri); - const code = query.code; - if (query.state !== state) { - reject('Received mismatched state'); - return; - } - - try { - const result = await fetch(`https://${AUTH_RELAY_SERVER}/token?code=${code}&state=${state}`, { - method: 'POST', - headers: { - Accept: 'application/json' - } - }); - - if (result.ok) { - const json = await result.json(); - Logger.info('Token exchange success!'); - resolve(json.access_token); - } else { - reject(result.statusText); - } - } catch (ex) { - reject(ex); - } - }; function parseQuery(uri: vscode.Uri) { return uri.query.split('&').reduce((prev: any, current) => { @@ -67,6 +38,9 @@ function parseQuery(uri: vscode.Uri) { export class GitHubServer { private _statusBarItem: vscode.StatusBarItem | undefined; + private _pendingStates = new Map(); + private _codeExchangePromises = new Map>(); + private isTestEnvironment(url: vscode.Uri): boolean { return url.authority === 'vscode-web-test-playground.azurewebsites.net' || url.authority.startsWith('localhost:'); } @@ -91,18 +65,63 @@ export class GitHubServer { this.updateStatusBarItem(false); return token; } else { + const existingStates = this._pendingStates.get(scopes) || []; + this._pendingStates.set(scopes, [...existingStates, state]); + const uri = vscode.Uri.parse(`https://${AUTH_RELAY_SERVER}/authorize/?callbackUri=${encodeURIComponent(callbackUri.toString())}&scope=${scopes}&state=${state}&responseType=code&authServer=https://github.com`); await vscode.env.openExternal(uri); } + // Register a single listener for the URI callback, in case the user starts the login process multiple times + // before completing it. + let existingPromise = this._codeExchangePromises.get(scopes); + if (!existingPromise) { + existingPromise = promiseFromEvent(uriHandler.event, this.exchangeCodeForToken(scopes)); + this._codeExchangePromises.set(scopes, existingPromise); + } + return Promise.race([ - promiseFromEvent(uriHandler.event, exchangeCodeForToken(state)), + existingPromise, promiseFromEvent(onDidManuallyProvideToken.event) ]).finally(() => { + this._pendingStates.delete(scopes); + this._codeExchangePromises.delete(scopes); this.updateStatusBarItem(false); }); } + private exchangeCodeForToken: (scopes: string) => PromiseAdapter = + (scopes) => async (uri, resolve, reject) => { + Logger.info('Exchanging code for token...'); + const query = parseQuery(uri); + const code = query.code; + + const acceptedStates = this._pendingStates.get(scopes) || []; + if (!acceptedStates.includes(query.state)) { + reject('Received mismatched state'); + return; + } + + try { + const result = await fetch(`https://${AUTH_RELAY_SERVER}/token?code=${code}&state=${query.state}`, { + method: 'POST', + headers: { + Accept: 'application/json' + } + }); + + if (result.ok) { + const json = await result.json(); + Logger.info('Token exchange success!'); + resolve(json.access_token); + } else { + reject(result.statusText); + } + } catch (ex) { + reject(ex); + } + }; + private updateStatusBarItem(isStart?: boolean) { if (isStart && !this._statusBarItem) { this._statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); diff --git a/extensions/microsoft-authentication/src/AADHelper.ts b/extensions/microsoft-authentication/src/AADHelper.ts index a30d58be4a5..8220adc6cb4 100644 --- a/extensions/microsoft-authentication/src/AADHelper.ts +++ b/extensions/microsoft-authentication/src/AADHelper.ts @@ -95,6 +95,11 @@ export class AzureActiveDirectoryService { private _uriHandler: UriEventHandler; private _disposables: vscode.Disposable[] = []; + // Used to keep track of current requests when not using the local server approach. + private _pendingStates = new Map(); + private _codeExchangePromises = new Map>(); + private _codeVerfifiers = new Map(); + constructor() { this._uriHandler = new UriEventHandler(); this._disposables.push(vscode.window.registerUriHandler(this._uriHandler)); @@ -385,10 +390,28 @@ export class AzureActiveDirectoryService { }, 1000 * 60 * 5); }); - return Promise.race([this.handleCodeResponse(state, codeVerifier, scope), timeoutPromise]); + const existingStates = this._pendingStates.get(scope) || []; + this._pendingStates.set(scope, [...existingStates, state]); + + // Register a single listener for the URI callback, in case the user starts the login process multiple times + // before completing it. + let existingPromise = this._codeExchangePromises.get(scope); + if (!existingPromise) { + existingPromise = this.handleCodeResponse(scope); + this._codeExchangePromises.set(scope, existingPromise); + } + + this._codeVerfifiers.set(state, codeVerifier); + + return Promise.race([existingPromise, timeoutPromise]) + .finally(() => { + this._pendingStates.delete(scope); + this._codeExchangePromises.delete(scope); + this._codeVerfifiers.delete(state); + }); } - private async handleCodeResponse(state: string, codeVerifier: string, scope: string): Promise { + private async handleCodeResponse(scope: string): Promise { let uriEventListener: vscode.Disposable; return new Promise((resolve: (value: vscode.AuthenticationSession) => void, reject) => { uriEventListener = this._uriHandler.event(async (uri: vscode.Uri) => { @@ -396,12 +419,18 @@ export class AzureActiveDirectoryService { const query = parseQuery(uri); const code = query.code; + const acceptedStates = this._pendingStates.get(scope) || []; // Workaround double encoding issues of state in web - if (query.state !== state && decodeURIComponent(query.state) !== state) { + if (!acceptedStates.includes(query.state) && !acceptedStates.includes(decodeURIComponent(query.state))) { throw new Error('State does not match.'); } - const token = await this.exchangeCodeForToken(code, codeVerifier, scope); + const verifier = this._codeVerfifiers.get(query.state) ?? this._codeVerfifiers.get(decodeURIComponent(query.state)); + if (!verifier) { + throw new Error('No available code verifier'); + } + + const token = await this.exchangeCodeForToken(code, verifier, scope); this.setToken(token, scope); const session = await this.convertToSession(token);