diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index 848f89cd993..581d2badaf0 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -202,6 +202,10 @@ "name": "vs/workbench/services/actions", "project": "vscode-workbench" }, + { + "name": "vs/workbench/services/authToken", + "project": "vscode-workbench" + }, { "name": "vs/workbench/services/bulkEdit", "project": "vscode-workbench" diff --git a/src/vs/code/electron-browser/sharedProcess/sharedProcessMain.ts b/src/vs/code/electron-browser/sharedProcess/sharedProcessMain.ts index 9ddf3c0eca9..1492e7710c1 100644 --- a/src/vs/code/electron-browser/sharedProcess/sharedProcessMain.ts +++ b/src/vs/code/electron-browser/sharedProcess/sharedProcessMain.ts @@ -59,7 +59,7 @@ import { IElectronService } from 'vs/platform/electron/node/electron'; import { LoggerService } from 'vs/platform/log/node/loggerService'; import { UserDataSyncLogService } from 'vs/platform/userDataSync/common/userDataSyncLog'; import { IAuthTokenService } from 'vs/platform/auth/common/auth'; -import { AuthTokenService } from 'vs/platform/auth/common/authTokenService'; +import { AuthTokenService } from 'vs/platform/auth/electron-browser/authTokenService'; import { AuthTokenChannel } from 'vs/platform/auth/common/authTokenIpc'; import { ICredentialsService } from 'vs/platform/credentials/common/credentials'; import { KeytarCredentialsService } from 'vs/platform/credentials/node/credentialsService'; diff --git a/src/vs/platform/auth/common/auth.ts b/src/vs/platform/auth/common/auth.ts index 7b2961d30b0..99102a0fe93 100644 --- a/src/vs/platform/auth/common/auth.ts +++ b/src/vs/platform/auth/common/auth.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; -import { Event } from 'vs/base/common/event'; +import { Event, Emitter } from 'vs/base/common/event'; +import { URI } from 'vs/base/common/uri'; export const enum AuthTokenStatus { Disabled = 'Disabled', @@ -19,11 +20,10 @@ export interface IAuthTokenService { readonly status: AuthTokenStatus; readonly onDidChangeStatus: Event; + readonly _onDidGetCallback: Emitter; - getToken(): Promise; - updateToken(token: string): Promise; + getToken(): Promise; refreshToken(): Promise; - deleteToken(): Promise; - + login(callbackUri?: URI): Promise; + logout(): Promise; } - diff --git a/src/vs/platform/auth/common/authTokenIpc.ts b/src/vs/platform/auth/common/authTokenIpc.ts index a5c78c4a8dd..99a2111750c 100644 --- a/src/vs/platform/auth/common/authTokenIpc.ts +++ b/src/vs/platform/auth/common/authTokenIpc.ts @@ -22,9 +22,12 @@ export class AuthTokenChannel implements IServerChannel { switch (command) { case '_getInitialStatus': return Promise.resolve(this.service.status); case 'getToken': return this.service.getToken(); - case 'updateToken': return this.service.updateToken(args[0]); + case 'exchangeCodeForToken': + this.service._onDidGetCallback.fire(args); + return Promise.resolve(); case 'refreshToken': return this.service.refreshToken(); - case 'deleteToken': return this.service.deleteToken(); + case 'login': return this.service.login(args); + case 'logout': return this.service.logout(); } throw new Error('Invalid call'); } diff --git a/src/vs/platform/auth/electron-browser/authTokenService.ts b/src/vs/platform/auth/electron-browser/authTokenService.ts new file mode 100644 index 00000000000..9b70de53d72 --- /dev/null +++ b/src/vs/platform/auth/electron-browser/authTokenService.ts @@ -0,0 +1,266 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as crypto from 'crypto'; +import * as https from 'https'; +import { Event, Emitter } from 'vs/base/common/event'; +import { IAuthTokenService, AuthTokenStatus } from 'vs/platform/auth/common/auth'; +import { ICredentialsService } from 'vs/platform/credentials/common/credentials'; +import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; +import { IProductService } from 'vs/platform/product/common/productService'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { URI } from 'vs/base/common/uri'; +import { generateUuid } from 'vs/base/common/uuid'; +import { shell } from 'electron'; + +const SERVICE_NAME = 'VS Code'; +const ACCOUNT = 'MyAccount'; + +const redirectUrlAAD = 'https://vscode-redirect.azurewebsites.net/'; +const activeDirectoryEndpointUrl = 'https://login.microsoftonline.com/'; +const activeDirectoryResourceId = 'https://management.core.windows.net/'; + +const clientId = 'aebc6443-996d-45c2-90f0-388ff96faa56'; +const tenantId = 'common'; + +function parseQuery(uri: URI) { + return uri.query.split('&').reduce((prev: any, current) => { + const queryString = current.split('='); + prev[queryString[0]] = queryString[1]; + return prev; + }, {}); +} + +function toQuery(obj: any): string { + return Object.keys(obj).map(key => `${key}=${obj[key]}`).join('&'); +} + +function toBase64UrlEncoding(base64string: string) { + return base64string.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); // Need to use base64url encoding +} + +export interface IToken { + expiresIn: string; // How long access token is valid, in seconds + expiresOn: string; // When the access token expires in epoch time + accessToken: string; + refreshToken: string; +} + +export class AuthTokenService extends Disposable implements IAuthTokenService { + _serviceBrand: undefined; + + private _status: AuthTokenStatus = AuthTokenStatus.Disabled; + get status(): AuthTokenStatus { return this._status; } + private _onDidChangeStatus: Emitter = this._register(new Emitter()); + readonly onDidChangeStatus: Event = this._onDidChangeStatus.event; + + public readonly _onDidGetCallback: Emitter = this._register(new Emitter()); + readonly onDidGetCallback: Event = this._onDidGetCallback.event; + + private _activeToken: IToken | undefined; + + constructor( + @ICredentialsService private readonly credentialsService: ICredentialsService, + @IProductService productService: IProductService, + @IConfigurationService configurationService: IConfigurationService, + ) { + super(); + if (productService.settingsSyncStoreUrl && configurationService.getValue('configurationSync.enableAuth')) { + this.credentialsService.getPassword(SERVICE_NAME, ACCOUNT).then(storedRefreshToken => { + if (storedRefreshToken) { + this.refresh(storedRefreshToken); + } else { + this._status = AuthTokenStatus.Inactive; + } + }); + } + } + + public async login(callbackUri: URI): Promise { + const nonce = generateUuid(); + const port = (callbackUri.authority.match(/:([0-9]*)$/) || [])[1] || (callbackUri.scheme === 'https' || callbackUri.scheme === 'http' ? 443 : 80); + const state = `${callbackUri.scheme},${port},${encodeURIComponent(nonce)},${encodeURIComponent(callbackUri.query)}`; + const signInUrl = `${activeDirectoryEndpointUrl}${tenantId}/oauth2/authorize`; + + const codeVerifier = toBase64UrlEncoding(crypto.randomBytes(32).toString('base64')); + const codeChallenge = toBase64UrlEncoding(crypto.createHash('sha256').update(codeVerifier).digest('base64')); + + let uri = URI.parse(signInUrl); + uri = uri.with({ + query: `response_type=code&client_id=${encodeURIComponent(clientId)}&redirect_uri=${redirectUrlAAD}&state=${encodeURIComponent(state)}&resource=${activeDirectoryResourceId}&prompt=select_account&code_challenge_method=S256&code_challenge=${codeChallenge}` + }); + + await shell.openExternal(uri.toString(true)); + + const timeoutPromise = new Promise((resolve: (value: IToken) => void, reject) => { + const wait = setTimeout(() => { + clearTimeout(wait); + reject('Login timed out.'); + }, 1000 * 60 * 5); + }); + + return Promise.race([this.exchangeCodeForToken(clientId, tenantId, codeVerifier, state), timeoutPromise]).then(token => { + this.setToken(token); + }); + } + + public getToken(): Promise { + if (this.status === AuthTokenStatus.Disabled) { + throw new Error('Not enabled'); + } + return Promise.resolve(this._activeToken?.accessToken); + } + + public async refreshToken(): Promise { + if (this.status === AuthTokenStatus.Disabled) { + throw new Error('Not enabled'); + } + + if (!this._activeToken) { + throw new Error('No token to refresh'); + } + + this.refresh(this._activeToken.refreshToken); + } + + private setToken(token: IToken) { + this._activeToken = token; + this.credentialsService.setPassword(SERVICE_NAME, ACCOUNT, token.refreshToken); + this.setStatus(AuthTokenStatus.Active); + } + + private async exchangeCodeForToken(clientId: string, tenantId: string, codeVerifier: string, state: string): Promise { + let uriEventListener: IDisposable; + return new Promise((resolve: (value: IToken) => void, reject) => { + uriEventListener = this.onDidGetCallback(async (uri: URI) => { + try { + const query = parseQuery(uri); + const code = query.code; + + if (query.state !== state) { + return; + } + + const postData = toQuery({ + grant_type: 'authorization_code', + code: code, + client_id: clientId, + code_verifier: codeVerifier, + redirect_uri: redirectUrlAAD + }); + + const post = https.request({ + host: 'login.microsoftonline.com', + path: `/${tenantId}/oauth2/token`, + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': postData.length + } + }, result => { + const buffer: Buffer[] = []; + result.on('data', (chunk: Buffer) => { + buffer.push(chunk); + }); + result.on('end', () => { + if (result.statusCode === 200) { + const json = JSON.parse(Buffer.concat(buffer).toString()); + resolve({ + expiresIn: json.access_token, + expiresOn: json.expires_on, + accessToken: json.access_token, + refreshToken: json.refresh_token + }); + } else { + reject(new Error('Bad!')); + } + }); + }); + + post.write(postData); + + post.end(); + post.on('error', err => { + reject(err); + }); + + } catch (e) { + reject(e); + } + }); + }).then(result => { + uriEventListener.dispose(); + return result; + }).catch(err => { + uriEventListener.dispose(); + throw err; + }); + } + + private async refresh(refreshToken: string): Promise { + return new Promise((resolve, reject) => { + const postData = toQuery({ + refresh_token: refreshToken, + client_id: clientId, + grant_type: 'refresh_token', + resource: activeDirectoryResourceId + }); + + const post = https.request({ + host: 'login.microsoftonline.com', + path: `/${tenantId}/oauth2/token`, + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': postData.length + } + }, result => { + const buffer: Buffer[] = []; + result.on('data', (chunk: Buffer) => { + buffer.push(chunk); + }); + result.on('end', () => { + if (result.statusCode === 200) { + const json = JSON.parse(Buffer.concat(buffer).toString()); + this.setToken({ + expiresIn: json.access_token, + expiresOn: json.expires_on, + accessToken: json.access_token, + refreshToken: json.refresh_token + }); + resolve(); + } else { + reject(new Error('Bad!')); + } + }); + }); + + post.write(postData); + + post.end(); + post.on('error', err => { + reject(err); + }); + }); + } + + async logout(): Promise { + if (this.status === AuthTokenStatus.Disabled) { + throw new Error('Not enabled'); + } + await this.credentialsService.deletePassword(SERVICE_NAME, ACCOUNT); + this._activeToken = undefined; + this.setStatus(AuthTokenStatus.Inactive); + } + + private setStatus(status: AuthTokenStatus): void { + if (this._status !== status) { + this._status = status; + this._onDidChangeStatus.fire(status); + } + } + +} + diff --git a/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts b/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts index 92cb1c09c88..f1a0ded961b 100644 --- a/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts +++ b/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts @@ -25,7 +25,6 @@ import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/ import { isEqual } from 'vs/base/common/resources'; import { IEditorInput } from 'vs/workbench/common/editor'; import { IAuthTokenService, AuthTokenStatus } from 'vs/platform/auth/common/auth'; -import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; import { timeout } from 'vs/base/common/async'; const CONTEXT_AUTH_TOKEN_STATE = new RawContextKey('authTokenStatus', AuthTokenStatus.Inactive); @@ -51,7 +50,6 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo @ITextFileService private readonly textFileService: ITextFileService, @IHistoryService private readonly historyService: IHistoryService, @IWorkbenchEnvironmentService private readonly workbenchEnvironmentService: IWorkbenchEnvironmentService, - @IQuickInputService private readonly quickInputService: IQuickInputService, ) { super(); this.syncStatusContext = CONTEXT_SYNC_STATE.bindTo(contextKeyService); @@ -138,14 +136,11 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo } private async signIn(): Promise { - const token = await this.quickInputService.input({ placeHolder: localize('enter token', "Please provide the auth bearer token"), ignoreFocusLost: true, }); - if (token) { - await this.authTokenService.updateToken(token); - } + return this.authTokenService.login(); } private async signOut(): Promise { - await this.authTokenService.deleteToken(); + await this.authTokenService.logout(); } private async continueSync(): Promise { diff --git a/src/vs/platform/auth/common/authTokenService.ts b/src/vs/workbench/services/authToken/browser/authTokenService.ts similarity index 73% rename from src/vs/platform/auth/common/authTokenService.ts rename to src/vs/workbench/services/authToken/browser/authTokenService.ts index 322ecfb2b09..430a11f7bd8 100644 --- a/src/vs/platform/auth/common/authTokenService.ts +++ b/src/vs/workbench/services/authToken/browser/authTokenService.ts @@ -3,12 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { localize } from 'vs/nls'; import { Event, Emitter } from 'vs/base/common/event'; import { IAuthTokenService, AuthTokenStatus } from 'vs/platform/auth/common/auth'; import { ICredentialsService } from 'vs/platform/credentials/common/credentials'; import { Disposable } from 'vs/base/common/lifecycle'; import { IProductService } from 'vs/platform/product/common/productService'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; +import { URI } from 'vs/base/common/uri'; const SERVICE_NAME = 'VS Code'; const ACCOUNT = 'MyAccount'; @@ -21,10 +24,13 @@ export class AuthTokenService extends Disposable implements IAuthTokenService { private _onDidChangeStatus: Emitter = this._register(new Emitter()); readonly onDidChangeStatus: Event = this._onDidChangeStatus.event; + readonly _onDidGetCallback: Emitter = this._register(new Emitter()); + constructor( @ICredentialsService private readonly credentialsService: ICredentialsService, @IProductService productService: IProductService, @IConfigurationService configurationService: IConfigurationService, + @IQuickInputService private readonly quickInputService: IQuickInputService ) { super(); if (productService.settingsSyncStoreUrl && configurationService.getValue('configurationSync.enableAuth')) { @@ -37,29 +43,35 @@ export class AuthTokenService extends Disposable implements IAuthTokenService { } } - getToken(): Promise { + async getToken(): Promise { if (this.status === AuthTokenStatus.Disabled) { throw new Error('Not enabled'); } - return this.credentialsService.getPassword(SERVICE_NAME, ACCOUNT); + + const token = await this.credentialsService.getPassword(SERVICE_NAME, ACCOUNT); + if (token) { + return token; + } + + return; } - async updateToken(token: string): Promise { - if (this.status === AuthTokenStatus.Disabled) { - throw new Error('Not enabled'); + async login(): Promise { + const token = await this.quickInputService.input({ placeHolder: localize('enter token', "Please provide the auth bearer token"), ignoreFocusLost: true, }); + if (token) { + await this.credentialsService.setPassword(SERVICE_NAME, ACCOUNT, token); + this.setStatus(AuthTokenStatus.Active); } - await this.credentialsService.setPassword(SERVICE_NAME, ACCOUNT, token); - this.setStatus(AuthTokenStatus.Active); } async refreshToken(): Promise { if (this.status === AuthTokenStatus.Disabled) { throw new Error('Not enabled'); } - await this.deleteToken(); + await this.logout(); } - async deleteToken(): Promise { + async logout(): Promise { if (this.status === AuthTokenStatus.Disabled) { throw new Error('Not enabled'); } @@ -75,4 +87,3 @@ export class AuthTokenService extends Disposable implements IAuthTokenService { } } - diff --git a/src/vs/workbench/services/authToken/electron-browser/authTokenService.ts b/src/vs/workbench/services/authToken/electron-browser/authTokenService.ts index b5a2b2d0c0a..7a06e375569 100644 --- a/src/vs/workbench/services/authToken/electron-browser/authTokenService.ts +++ b/src/vs/workbench/services/authToken/electron-browser/authTokenService.ts @@ -9,6 +9,8 @@ import { Emitter, Event } from 'vs/base/common/event'; import { IChannel } from 'vs/base/parts/ipc/common/ipc'; import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { IAuthTokenService, AuthTokenStatus } from 'vs/platform/auth/common/auth'; +import { IURLService } from 'vs/platform/url/common/url'; +import { URI } from 'vs/base/common/uri'; export class AuthTokenService extends Disposable implements IAuthTokenService { @@ -21,8 +23,11 @@ export class AuthTokenService extends Disposable implements IAuthTokenService { private _onDidChangeStatus: Emitter = this._register(new Emitter()); readonly onDidChangeStatus: Event = this._onDidChangeStatus.event; + readonly _onDidGetCallback: Emitter = this._register(new Emitter()); + constructor( - @ISharedProcessService sharedProcessService: ISharedProcessService + @ISharedProcessService sharedProcessService: ISharedProcessService, + @IURLService private readonly urlService: IURLService ) { super(); this.channel = sharedProcessService.getChannel('authToken'); @@ -30,22 +35,34 @@ export class AuthTokenService extends Disposable implements IAuthTokenService { this.updateStatus(status); this._register(this.channel.listen('onDidChangeStatus')(status => this.updateStatus(status))); }); + + this.urlService.registerHandler(this); + } + + handleURL(uri: URI) { + if (uri.authority === 'vscode.login') { + this.channel.call('exchangeCodeForToken', uri); + return Promise.resolve(true); + } else { + return Promise.resolve(false); + } } getToken(): Promise { return this.channel.call('getToken'); } - updateToken(token: string): Promise { - return this.channel.call('updateToken', [token]); + login(): Promise { + const callbackUri = this.urlService.create({ authority: 'vscode.login ' }); + return this.channel.call('login', callbackUri); } refreshToken(): Promise { return this.channel.call('getToken'); } - deleteToken(): Promise { - return this.channel.call('deleteToken'); + logout(): Promise { + return this.channel.call('logout'); } private async updateStatus(status: AuthTokenStatus): Promise { diff --git a/src/vs/workbench/workbench.web.main.ts b/src/vs/workbench/workbench.web.main.ts index bed3663cfcb..0e6601077d3 100644 --- a/src/vs/workbench/workbench.web.main.ts +++ b/src/vs/workbench/workbench.web.main.ts @@ -64,7 +64,7 @@ import { NoOpTunnelService } from 'vs/platform/remote/common/tunnelService'; import { ILoggerService } from 'vs/platform/log/common/log'; import { FileLoggerService } from 'vs/platform/log/common/fileLogService'; import { IAuthTokenService } from 'vs/platform/auth/common/auth'; -import { AuthTokenService } from 'vs/platform/auth/common/authTokenService'; +import { AuthTokenService } from 'vs/workbench/services/authToken/browser/authTokenService'; import { IUserDataSyncStoreService, IUserDataSyncService, IUserDataSyncLogService } from 'vs/platform/userDataSync/common/userDataSync'; import { UserDataSyncLogService } from 'vs/platform/userDataSync/common/userDataSyncLog'; import { UserDataSyncStoreService } from 'vs/platform/userDataSync/common/userDataSyncStoreService';