diff --git a/extensions/microsoft-authentication/src/AADHelper.ts b/extensions/microsoft-authentication/src/AADHelper.ts index a70812f5c74..724311e0458 100644 --- a/extensions/microsoft-authentication/src/AADHelper.ts +++ b/extensions/microsoft-authentication/src/AADHelper.ts @@ -22,8 +22,8 @@ const localize = nls.loadMessageBundle(); const redirectUrl = 'https://vscode-redirect.azurewebsites.net/'; const loginEndpointUrl = 'https://login.microsoftonline.com/'; -const clientId = 'aebc6443-996d-45c2-90f0-388ff96faa56'; -const tenant = 'organizations'; +const DEFAULT_CLIENT_ID = 'aebc6443-996d-45c2-90f0-388ff96faa56'; +const DEFAULT_TENANT = 'organizations'; interface IToken { accessToken?: string; // When unable to refresh due to network problems, the access token becomes undefined @@ -79,6 +79,14 @@ export interface IMicrosoftTokens { idToken?: string; } +interface IScopeData { + scopes: string[], + scopeStr: string, + scopesToSend: string, + clientId: string, + tenant: string +} + function parseQuery(uri: vscode.Uri) { return uri.query.split('&').reduce((prev: any, current) => { const queryString = current.split('='); @@ -138,7 +146,16 @@ export class AzureActiveDirectoryService { } try { - await this.refreshToken(session.refreshToken, session.scope, session.id); + const scopes = session.scope.split(' '); + const scopeData: IScopeData = { + scopes, + scopeStr: session.scope, + // filter our special scopes + scopesToSend: scopes.filter(s => !s.startsWith('VSCODE_')).join(' '), + clientId: this.getClientId(scopes), + tenant: this.getTenantId(scopes), + }; + await this.refreshToken(session.refreshToken, scopeData, session.id); } catch (e) { // If we aren't connected to the internet, then wait and try to refresh again later. if (e.message === REFRESH_NETWORK_FAILURE) { @@ -194,7 +211,16 @@ export class AzureActiveDirectoryService { const matchesExisting = this._tokens.some(token => token.scope === session.scope && token.sessionId === session.id); if (!matchesExisting && session.refreshToken) { try { - const token = await this.refreshToken(session.refreshToken, session.scope, session.id); + const scopes = session.scope.split(' '); + const scopeData: IScopeData = { + scopes, + scopeStr: session.scope, + // filter our special scopes + scopesToSend: scopes.filter(s => !s.startsWith('VSCODE_')).join(' '), + clientId: this.getClientId(scopes), + tenant: this.getTenantId(scopes), + }; + const token = await this.refreshToken(session.refreshToken, scopeData, session.id); added.push(this.convertToSessionSync(token)); } catch (e) { // Network failures will automatically retry on next poll. @@ -280,7 +306,16 @@ export class AzureActiveDirectoryService { try { Logger.info(`Token expired or unavailable (for scopes ${token.scope}), trying refresh`); - const refreshedToken = await this.refreshToken(token.refreshToken, token.scope, token.sessionId); + const scopes = token.scope.split(' '); + const scopeData: IScopeData = { + scopes, + scopeStr: token.scope, + // filter our special scopes + scopesToSend: scopes.filter(s => !s.startsWith('VSCODE_')).join(' '), + clientId: this.getClientId(scopes), + tenant: this.getTenantId(scopes), + }; + const refreshedToken = await this.refreshToken(token.refreshToken, scopeData, token.sessionId); if (refreshedToken.accessToken) { return { accessToken: refreshedToken.accessToken, @@ -329,16 +364,25 @@ export class AzureActiveDirectoryService { return Promise.all(matchingTokens.map(token => this.convertToSession(token))); } - public async createSession(scope: string): Promise { - Logger.info(`Logging in for the following scopes: ${scope}`); - if (!scope.includes('offline_access')) { + public async createSession(scopes: string[]): Promise { + const scopeData: IScopeData = { + scopes, + scopeStr: scopes.join(' '), + // filter our special scopes + scopesToSend: scopes.filter(s => !s.startsWith('VSCODE_')).join(' '), + clientId: this.getClientId(scopes), + tenant: this.getTenantId(scopes), + }; + + Logger.info(`Logging in for the following scopes: ${scopeData.scopeStr}`); + if (!scopeData.scopes.includes('offline_access')) { Logger.info('Warning: The \'offline_access\' scope was not included, so the generated token will not be able to be refreshed.'); } const runsRemote = vscode.env.remoteName !== undefined; const runsServerless = vscode.env.remoteName === undefined && vscode.env.uiKind === vscode.UIKind.Web; if (runsRemote || runsServerless) { - return this.loginWithoutLocalServer(scope); + return this.loginWithoutLocalServer(scopeData); } const nonce = randomBytes(16).toString('base64'); @@ -365,7 +409,8 @@ export class AzureActiveDirectoryService { const codeVerifier = toBase64UrlEncoding(randomBytes(32).toString('base64')); const codeChallenge = toBase64UrlEncoding(await sha256(codeVerifier)); - const loginUrl = `${loginEndpointUrl}${tenant}/oauth2/v2.0/authorize?response_type=code&response_mode=query&client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(redirectUrl)}&state=${state}&scope=${encodeURIComponent(scope)}&prompt=select_account&code_challenge_method=S256&code_challenge=${codeChallenge}`; + + const loginUrl = `${loginEndpointUrl}${scopeData.tenant}/oauth2/v2.0/authorize?response_type=code&response_mode=query&client_id=${encodeURIComponent(scopeData.clientId)}&redirect_uri=${encodeURIComponent(redirectUrl)}&state=${state}&scope=${encodeURIComponent(scopeData.scopesToSend)}&prompt=select_account&code_challenge_method=S256&code_challenge=${codeChallenge}`; redirectReq.res.writeHead(302, { Location: loginUrl }); redirectReq.res.end(); @@ -377,9 +422,9 @@ export class AzureActiveDirectoryService { if ('err' in codeRes) { throw codeRes.err; } - token = await this.exchangeCodeForToken(codeRes.code, codeVerifier, scope); - await this.setToken(token, scope); - Logger.info(`Login successful for scopes: ${scope}`); + token = await this.exchangeCodeForToken(codeRes.code, codeVerifier, scopeData); + await this.setToken(token, scopeData); + Logger.info(`Login successful for scopes: ${scopeData.scopeStr}`); res.writeHead(302, { Location: '/' }); const session = await this.convertToSession(token); return session; @@ -390,11 +435,11 @@ export class AzureActiveDirectoryService { res.end(); } } catch (e) { - Logger.error(`Error creating session for scopes: ${scope} Error: ${e}`); + Logger.error(`Error creating session for scopes: ${scopeData.scopeStr} Error: ${e}`); // If the error was about starting the server, try directly hitting the login endpoint instead if (e.message === 'Error listening to server' || e.message === 'Closed' || e.message === 'Timeout waiting for port') { - return this.loginWithoutLocalServer(scope); + return this.loginWithoutLocalServer(scopeData); } throw e; @@ -426,18 +471,18 @@ export class AzureActiveDirectoryService { } } - private async loginWithoutLocalServer(scope: string): Promise { + private async loginWithoutLocalServer(scopeData: IScopeData): Promise { const callbackUri = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://vscode.microsoft-authentication`)); const nonce = randomBytes(16).toString('base64'); const port = (callbackUri.authority.match(/:([0-9]*)$/) || [])[1] || (callbackUri.scheme === 'https' ? 443 : 80); const callbackEnvironment = this.getCallbackEnvironment(callbackUri); const state = `${callbackEnvironment},${port},${encodeURIComponent(nonce)},${encodeURIComponent(callbackUri.query)}`; - const signInUrl = `${loginEndpointUrl}${tenant}/oauth2/v2.0/authorize`; + const signInUrl = `${loginEndpointUrl}${scopeData.tenant}/oauth2/v2.0/authorize`; let uri = vscode.Uri.parse(signInUrl); const codeVerifier = toBase64UrlEncoding(randomBytes(32).toString('base64')); const codeChallenge = toBase64UrlEncoding(await sha256(codeVerifier)); uri = uri.with({ - query: `response_type=code&client_id=${encodeURIComponent(clientId)}&response_mode=query&redirect_uri=${redirectUrl}&state=${state}&scope=${scope}&prompt=select_account&code_challenge_method=S256&code_challenge=${codeChallenge}` + query: `response_type=code&client_id=${encodeURIComponent(scopeData.clientId)}&response_mode=query&redirect_uri=${redirectUrl}&state=${state}&scope=${scopeData.scopesToSend}&prompt=select_account&code_challenge_method=S256&code_challenge=${codeChallenge}` }); vscode.env.openExternal(uri); @@ -448,36 +493,35 @@ export class AzureActiveDirectoryService { }, 1000 * 60 * 5); }); - const existingStates = this._pendingStates.get(scope) || []; - this._pendingStates.set(scope, [...existingStates, state]); + const existingStates = this._pendingStates.get(scopeData.scopeStr) || []; + this._pendingStates.set(scopeData.scopeStr, [...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); + let existingPromise = this._codeExchangePromises.get(scopeData.scopeStr); if (!existingPromise) { - existingPromise = this.handleCodeResponse(scope); - this._codeExchangePromises.set(scope, existingPromise); + existingPromise = this.handleCodeResponse(scopeData); + this._codeExchangePromises.set(scopeData.scopeStr, existingPromise); } this._codeVerfifiers.set(state, codeVerifier); return Promise.race([existingPromise, timeoutPromise]) .finally(() => { - this._pendingStates.delete(scope); - this._codeExchangePromises.delete(scope); + this._pendingStates.delete(scopeData.scopeStr); + this._codeExchangePromises.delete(scopeData.scopeStr); this._codeVerfifiers.delete(state); }); } - private async handleCodeResponse(scope: string): Promise { + private async handleCodeResponse(scopeData: IScopeData): Promise { let uriEventListener: vscode.Disposable; return new Promise((resolve: (value: vscode.AuthenticationSession) => void, reject) => { uriEventListener = this._uriHandler.event(async (uri: vscode.Uri) => { try { const query = parseQuery(uri); const code = query.code; - - const acceptedStates = this._pendingStates.get(scope) || []; + const acceptedStates = this._pendingStates.get(scopeData.scopeStr) || []; // Workaround double encoding issues of state in web if (!acceptedStates.includes(query.state) && !acceptedStates.includes(decodeURIComponent(query.state))) { throw new Error('State does not match.'); @@ -488,8 +532,8 @@ export class AzureActiveDirectoryService { throw new Error('No available code verifier'); } - const token = await this.exchangeCodeForToken(code, verifier, scope); - await this.setToken(token, scope); + const token = await this.exchangeCodeForToken(code, verifier, scopeData); + await this.setToken(token, scopeData); const session = await this.convertToSession(token); resolve(session); @@ -506,8 +550,8 @@ export class AzureActiveDirectoryService { }); } - private async setToken(token: IToken, scope: string): Promise { - Logger.info(`Setting token for scopes: ${scope}`); + private async setToken(token: IToken, scopeData: IScopeData): Promise { + Logger.info(`Setting token for scopes: ${scopeData.scopeStr}`); const existingTokenIndex = this._tokens.findIndex(t => t.sessionId === token.sessionId); if (existingTokenIndex > -1) { this._tokens.splice(existingTokenIndex, 1, token); @@ -520,7 +564,7 @@ export class AzureActiveDirectoryService { if (token.expiresIn) { this._refreshTimeouts.set(token.sessionId, setTimeout(async () => { try { - const refreshedToken = await this.refreshToken(token.refreshToken, scope, token.sessionId); + const refreshedToken = await this.refreshToken(token.refreshToken, scopeData, token.sessionId); Logger.info('Triggering change session event...'); onDidChangeSessions.fire({ added: [], removed: [], changed: [this.convertToSessionSync(refreshedToken)] }); } catch (e) { @@ -536,7 +580,7 @@ export class AzureActiveDirectoryService { await this.storeTokenData(); } - private getTokenFromResponse(json: ITokenResponse, scope: string, existingId?: string): IToken { + private getTokenFromResponse(json: ITokenResponse, scopeData: IScopeData, existingId?: string): IToken { let claims = undefined; try { @@ -556,7 +600,7 @@ export class AzureActiveDirectoryService { accessToken: json.access_token, idToken: json.id_token, refreshToken: json.refresh_token, - scope, + scope: scopeData.scopeStr, sessionId: existingId || `${claims.tid}/${(claims.oid || (claims.altsecid || '' + claims.ipd || ''))}/${uuid()}`, account: { label: claims.email || claims.unique_name || claims.preferred_username || 'user@example.com', @@ -565,33 +609,51 @@ export class AzureActiveDirectoryService { }; } - private async exchangeCodeForToken(code: string, codeVerifier: string, scope: string): Promise { - Logger.info(`Exchanging login code for token for scopes: ${scope}`); + private getClientId(scopes: string[]) { + return scopes.reduce((prev, current) => { + if (current.startsWith('VSCODE_CLIENT_ID:')) { + return current.split('VSCODE_CLIENT_ID:')[1]; + } + return prev; + }, undefined) ?? DEFAULT_CLIENT_ID; + } + + private getTenantId(scopes: string[]) { + return scopes.reduce((prev, current) => { + if (current.startsWith('VSCODE_TENANT:')) { + return current.split('VSCODE_TENANT:')[1]; + } + return prev; + }, undefined) ?? DEFAULT_TENANT; + } + + private async exchangeCodeForToken(code: string, codeVerifier: string, scopeData: IScopeData): Promise { + Logger.info(`Exchanging login code for token for scopes: ${scopeData.scopeStr}`); try { const postData = querystring.stringify({ grant_type: 'authorization_code', code: code, - client_id: clientId, - scope: scope, + client_id: scopeData.clientId, + scope: scopeData.scopesToSend, code_verifier: codeVerifier, redirect_uri: redirectUrl }); const proxyEndpoints: { [providerId: string]: string } | undefined = await vscode.commands.executeCommand('workbench.getCodeExchangeProxyEndpoints'); const endpointUrl = proxyEndpoints?.microsoft || loginEndpointUrl; - const endpoint = `${endpointUrl}${tenant}/oauth2/v2.0/token`; + const endpoint = `${endpointUrl}${scopeData.tenant}/oauth2/v2.0/token`; - const json = await this.fetchTokenResponse(endpoint, postData, scope); - Logger.info(`Exchanging login code for token (for scopes: ${scope}) succeeded!`); - return this.getTokenFromResponse(json, scope); + const json = await this.fetchTokenResponse(endpoint, postData, scopeData); + Logger.info(`Exchanging login code for token (for scopes: ${scopeData.scopeStr}) succeeded!`); + return this.getTokenFromResponse(json, scopeData); } catch (e) { - Logger.error(`Error exchanging code for token (for scopes ${scope}): ${e}`); + Logger.error(`Error exchanging code for token (for scopes ${scopeData.scopeStr}): ${e}`); throw e; } } - private async refreshToken(refreshToken: string, scope: string, sessionId: string): Promise { - this._refreshingPromise = this.doRefreshToken(refreshToken, scope, sessionId); + private async refreshToken(refreshToken: string, scopeData: IScopeData, sessionId: string): Promise { + this._refreshingPromise = this.doRefreshToken(refreshToken, scopeData, sessionId); try { const result = await this._refreshingPromise; return result; @@ -600,7 +662,7 @@ export class AzureActiveDirectoryService { } } - private async fetchTokenResponse(endpoint: string, postData: string, scopes: string): Promise { + private async fetchTokenResponse(endpoint: string, postData: string, scopeData: IScopeData): Promise { let attempts = 0; while (attempts <= 3) { attempts++; @@ -621,7 +683,7 @@ export class AzureActiveDirectoryService { if (!result || result.status > 499) { if (attempts > 3) { - Logger.error(`Fetching token failed for scopes (${scopes}): ${result ? await result.text() : errorMessage}`); + Logger.error(`Fetching token failed for scopes (${scopeData.scopeStr}): ${result ? await result.text() : errorMessage}`); break; } // Exponential backoff @@ -640,34 +702,34 @@ export class AzureActiveDirectoryService { throw new Error(REFRESH_NETWORK_FAILURE); } - private async doRefreshToken(refreshToken: string, scope: string, sessionId: string): Promise { - Logger.info(`Refreshing token for scopes: ${scope}`); + private async doRefreshToken(refreshToken: string, scopeData: IScopeData, sessionId: string): Promise { + Logger.info(`Refreshing token for scopes: ${scopeData.scopeStr}`); const postData = querystring.stringify({ refresh_token: refreshToken, - client_id: clientId, + client_id: scopeData.clientId, grant_type: 'refresh_token', - scope: scope + scope: scopeData.scopesToSend }); const proxyEndpoints: { [providerId: string]: string } | undefined = await vscode.commands.executeCommand('workbench.getCodeExchangeProxyEndpoints'); const endpointUrl = proxyEndpoints?.microsoft || loginEndpointUrl; - const endpoint = `${endpointUrl}${tenant}/oauth2/v2.0/token`; + const endpoint = `${endpointUrl}${scopeData.tenant}/oauth2/v2.0/token`; try { - const json = await this.fetchTokenResponse(endpoint, postData, scope); - const token = this.getTokenFromResponse(json, scope, sessionId); - await this.setToken(token, scope); + const json = await this.fetchTokenResponse(endpoint, postData, scopeData); + const token = this.getTokenFromResponse(json, scopeData, sessionId); + await this.setToken(token, scopeData); Logger.info(`Token refresh success for scopes: ${token.scope}`); return token; } catch (e) { if (e.message === REFRESH_NETWORK_FAILURE) { // We were unable to refresh because of a network failure (i.e. the user lost internet access). // so set up a timeout to try again later. - this.pollForReconnect(sessionId, refreshToken, scope); + this.pollForReconnect(sessionId, refreshToken, scopeData); throw e; } vscode.window.showErrorMessage(localize('signOut', "You have been signed out because reading stored authentication information failed.")); - Logger.error(`Refreshing token failed (for scopes: ${scope}): ${e.message}`); + Logger.error(`Refreshing token failed (for scopes: ${scopeData.scopeStr}): ${e.message}`); throw new Error('Refreshing token failed'); } } @@ -692,15 +754,15 @@ export class AzureActiveDirectoryService { return token; } - private pollForReconnect(sessionId: string, refreshToken: string, scope: string): void { + private pollForReconnect(sessionId: string, refreshToken: string, scopeData: IScopeData): void { this.clearSessionTimeout(sessionId); - Logger.trace(`Setting up reconnection timeout for scopes: ${scope}...`); + Logger.trace(`Setting up reconnection timeout for scopes: ${scopeData.scopeStr}...`); this._refreshTimeouts.set(sessionId, setTimeout(async () => { try { - const refreshedToken = await this.refreshToken(refreshToken, scope, sessionId); + const refreshedToken = await this.refreshToken(refreshToken, scopeData, sessionId); onDidChangeSessions.fire({ added: [], removed: [], changed: [this.convertToSessionSync(refreshedToken)] }); } catch (e) { - this.pollForReconnect(sessionId, refreshToken, scope); + this.pollForReconnect(sessionId, refreshToken, scopeData); } }, 1000 * 60 * 30)); } diff --git a/extensions/microsoft-authentication/src/extension.ts b/extensions/microsoft-authentication/src/extension.ts index 22ec1b9ad35..09565f2b231 100644 --- a/extensions/microsoft-authentication/src/extension.ts +++ b/extensions/microsoft-authentication/src/extension.ts @@ -31,7 +31,7 @@ export async function activate(context: vscode.ExtensionContext) { scopes: JSON.stringify(scopes.map(s => s.replace(/[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}/i, '{guid}'))), }); - const session = await loginService.createSession(scopes.sort().join(' ')); + const session = await loginService.createSession(scopes.sort()); onDidChangeSessions.fire({ added: [session], removed: [], changed: [] }); return session; } catch (e) {