Skip to content

Commit 60beab2

Browse files
author
Rachel Macfarlane
committed
Move settings sync auth into built in extension
1 parent b13740b commit 60beab2

39 files changed

Lines changed: 1728 additions & 575 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
.vscode/**
2+
.vscode-test/**
3+
out/test/**
4+
src/**
5+
.gitignore
6+
vsc-extension-quickstart.md
7+
**/tsconfig.json
8+
**/tslint.json
9+
**/*.map
10+
**/*.ts
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
<!-- Copyright (C) Microsoft Corporation. All rights reserved. -->
22
<!DOCTYPE html>
33
<html>
4+
45
<head>
56
<meta charset="utf-8" />
67
<meta http-equiv="X-UA-Compatible" content="IE=edge">
78
<title>Azure Account - Sign In</title>
89
<meta name="viewport" content="width=device-width, initial-scale=1">
910
<link rel="stylesheet" type="text/css" media="screen" href="auth.css" />
1011
</head>
12+
1113
<body>
1214
<a class="branding" href="https://code.visualstudio.com/">
1315
Visual Studio Code
@@ -32,4 +34,5 @@
3234
}
3335
</script>
3436
</body>
37+
3538
</html>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"name": "login",
3+
"publisher": "vscode",
4+
"displayName": "Account",
5+
"description": "",
6+
"version": "0.0.1",
7+
"engines": {
8+
"vscode": "^1.42.0"
9+
},
10+
"categories": [
11+
"Other"
12+
],
13+
"enableProposedApi": true,
14+
"activationEvents": [
15+
"*"
16+
],
17+
"main": "./out/extension.js",
18+
"scripts": {
19+
"vscode:prepublish": "npm run compile",
20+
"compile": "tsc -p ./",
21+
"watch": "tsc -watch -p ./"
22+
},
23+
"devDependencies": {
24+
"typescript": "^3.7.4",
25+
"tslint": "^5.12.1",
26+
"@types/node": "^10.12.21",
27+
"@types/keytar": "^4.0.1",
28+
"@types/vscode": "^1.41.0"
29+
}
30+
}
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import * as crypto from 'crypto';
7+
import * as vscode from 'vscode';
8+
import * as https from 'https';
9+
import * as querystring from 'querystring';
10+
import { keychain } from './keychain';
11+
import { toBase64UrlEncoding } from './utils';
12+
import { createServer, startServer } from './authServer';
13+
14+
const redirectUrl = 'https://vscode-redirect.azurewebsites.net/';
15+
const loginEndpointUrl = 'https://login.microsoftonline.com/';
16+
const clientId = 'aebc6443-996d-45c2-90f0-388ff96faa56';
17+
const scope = 'https://management.core.windows.net/.default offline_access';
18+
const tenant = 'common';
19+
20+
interface IToken {
21+
expiresIn: string; // How long access token is valid, in seconds
22+
accessToken: string;
23+
refreshToken: string;
24+
}
25+
26+
export const onDidChangeAccounts = new vscode.EventEmitter<vscode.Account[]>();
27+
28+
export class AzureActiveDirectoryService {
29+
private _token: IToken | undefined;
30+
private _refreshTimeout: NodeJS.Timeout | undefined;
31+
32+
public async initialize(): Promise<void> {
33+
const existingRefreshToken = await keychain.getToken();
34+
if (existingRefreshToken) {
35+
await this.refreshToken(existingRefreshToken);
36+
}
37+
}
38+
39+
private tokenToAccount(token: IToken): vscode.Account {
40+
return {
41+
id: '',
42+
accessToken: token.accessToken,
43+
displayName: this.getDisplayNameFromToken(token.accessToken)
44+
};
45+
}
46+
47+
private getDisplayNameFromToken(accessToken: string): string {
48+
let displayName = 'user@example.com';
49+
try {
50+
// TODO fixme
51+
displayName = JSON.parse(atob(accessToken.split('.')[1]));
52+
} catch (e) {
53+
// Fall back to example display name
54+
}
55+
56+
return displayName;
57+
}
58+
59+
get accounts(): vscode.Account[] {
60+
return this._token ? [this.tokenToAccount(this._token)] : [];
61+
}
62+
63+
public async login(): Promise<void> {
64+
const nonce = crypto.randomBytes(16).toString('base64');
65+
const { server, redirectPromise, codePromise } = createServer(nonce);
66+
67+
let token: IToken | undefined;
68+
try {
69+
const port = await startServer(server);
70+
vscode.env.openExternal(vscode.Uri.parse(`http://localhost:${port}/signin?nonce=${encodeURIComponent(nonce)}`));
71+
72+
const redirectReq = await redirectPromise;
73+
if ('err' in redirectReq) {
74+
const { err, res } = redirectReq;
75+
res.writeHead(302, { Location: `/?error=${encodeURIComponent(err && err.message || 'Unknown error')}` });
76+
res.end();
77+
throw err;
78+
}
79+
80+
const host = redirectReq.req.headers.host || '';
81+
const updatedPortStr = (/^[^:]+:(\d+)$/.exec(Array.isArray(host) ? host[0] : host) || [])[1];
82+
const updatedPort = updatedPortStr ? parseInt(updatedPortStr, 10) : port;
83+
84+
const state = `${updatedPort},${encodeURIComponent(nonce)}`;
85+
86+
const codeVerifier = toBase64UrlEncoding(crypto.randomBytes(32).toString('base64'));
87+
const codeChallenge = toBase64UrlEncoding(crypto.createHash('sha256').update(codeVerifier).digest('base64'));
88+
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}`;
89+
90+
await redirectReq.res.writeHead(302, { Location: loginUrl });
91+
redirectReq.res.end();
92+
93+
const codeRes = await codePromise;
94+
const res = codeRes.res;
95+
96+
try {
97+
if ('err' in codeRes) {
98+
throw codeRes.err;
99+
}
100+
token = await this.exchangeCodeForToken(codeRes.code, codeVerifier);
101+
this.setToken(token);
102+
res.writeHead(302, { Location: '/' });
103+
res.end();
104+
} catch (err) {
105+
res.writeHead(302, { Location: `/?error=${encodeURIComponent(err && err.message || 'Unknown error')}` });
106+
res.end();
107+
}
108+
} finally {
109+
setTimeout(() => {
110+
server.close();
111+
}, 5000);
112+
}
113+
}
114+
115+
private async setToken(token: IToken): Promise<void> {
116+
this._token = token;
117+
118+
if (this._refreshTimeout) {
119+
clearTimeout(this._refreshTimeout);
120+
}
121+
122+
this._refreshTimeout = setTimeout(async () => {
123+
try {
124+
await this.refreshToken(token.refreshToken);
125+
} catch (e) {
126+
vscode.window.showErrorMessage(`You have been signed out.`);
127+
this._token = undefined;
128+
} finally {
129+
onDidChangeAccounts.fire(this.accounts);
130+
}
131+
}, 1000 * (parseInt(token.expiresIn) - 10));
132+
133+
await keychain.setToken(token.refreshToken);
134+
}
135+
136+
private async exchangeCodeForToken(code: string, codeVerifier: string): Promise<IToken> {
137+
return new Promise((resolve: (value: IToken) => void, reject) => {
138+
try {
139+
const postData = querystring.stringify({
140+
grant_type: 'authorization_code',
141+
code: code,
142+
client_id: clientId,
143+
scope: scope,
144+
code_verifier: codeVerifier,
145+
redirect_uri: redirectUrl
146+
});
147+
148+
const tokenUrl = vscode.Uri.parse(`${loginEndpointUrl}${tenant}/oauth2/v2.0/token`);
149+
150+
const post = https.request({
151+
host: tokenUrl.authority,
152+
path: tokenUrl.path,
153+
method: 'POST',
154+
headers: {
155+
'Content-Type': 'application/x-www-form-urlencoded',
156+
'Content-Length': postData.length
157+
}
158+
}, result => {
159+
const buffer: Buffer[] = [];
160+
result.on('data', (chunk: Buffer) => {
161+
buffer.push(chunk);
162+
});
163+
result.on('end', () => {
164+
if (result.statusCode === 200) {
165+
const json = JSON.parse(Buffer.concat(buffer).toString());
166+
resolve({
167+
expiresIn: json.expires_in,
168+
accessToken: json.access_token,
169+
refreshToken: json.refresh_token
170+
});
171+
} else {
172+
reject(new Error('Unable to login.'));
173+
}
174+
});
175+
});
176+
177+
post.write(postData);
178+
179+
post.end();
180+
post.on('error', err => {
181+
reject(err);
182+
});
183+
184+
} catch (e) {
185+
reject(e);
186+
}
187+
});
188+
}
189+
190+
private async refreshToken(refreshToken: string): Promise<IToken> {
191+
return new Promise((resolve: (value: IToken) => void, reject) => {
192+
const postData = querystring.stringify({
193+
refresh_token: refreshToken,
194+
client_id: clientId,
195+
grant_type: 'refresh_token',
196+
scope: scope
197+
});
198+
199+
const post = https.request({
200+
host: 'login.microsoftonline.com',
201+
path: `/${tenant}/oauth2/v2.0/token`,
202+
method: 'POST',
203+
headers: {
204+
'Content-Type': 'application/x-www-form-urlencoded',
205+
'Content-Length': postData.length
206+
}
207+
}, result => {
208+
const buffer: Buffer[] = [];
209+
result.on('data', (chunk: Buffer) => {
210+
buffer.push(chunk);
211+
});
212+
result.on('end', () => {
213+
if (result.statusCode === 200) {
214+
const json = JSON.parse(Buffer.concat(buffer).toString());
215+
const token = {
216+
expiresIn: json.expires_in,
217+
accessToken: json.access_token,
218+
refreshToken: json.refresh_token
219+
};
220+
this.setToken(token);
221+
resolve(token);
222+
} else {
223+
vscode.window.showInformationMessage(`error`);
224+
reject(new Error('Bad!'));
225+
}
226+
});
227+
});
228+
229+
post.write(postData);
230+
231+
post.end();
232+
post.on('error', err => {
233+
reject(err);
234+
});
235+
});
236+
}
237+
238+
public async logout() {
239+
delete this._token;
240+
await keychain.deleteToken();
241+
if (this._refreshTimeout) {
242+
clearTimeout(this._refreshTimeout);
243+
}
244+
}
245+
}

src/vs/platform/auth/electron-browser/authServer.ts renamed to extensions/vscode-account/src/authServer.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,46 @@ import * as http from 'http';
77
import * as url from 'url';
88
import * as fs from 'fs';
99
import * as net from 'net';
10-
import { getPathFromAmdModule } from 'vs/base/common/amd';
11-
import { assertIsDefined } from 'vs/base/common/types';
10+
import * as path from 'path';
1211

1312
interface Deferred<T> {
1413
resolve: (result: T | Promise<T>) => void;
1514
reject: (reason: any) => void;
1615
}
1716

17+
const _typeof = {
18+
number: 'number',
19+
string: 'string',
20+
undefined: 'undefined',
21+
object: 'object',
22+
function: 'function'
23+
};
24+
25+
/**
26+
* @returns whether the provided parameter is undefined.
27+
*/
28+
export function isUndefined(obj: any): obj is undefined {
29+
return typeof (obj) === _typeof.undefined;
30+
}
31+
32+
/**
33+
* @returns whether the provided parameter is undefined or null.
34+
*/
35+
export function isUndefinedOrNull(obj: any): obj is undefined | null {
36+
return isUndefined(obj) || obj === null;
37+
}
38+
39+
/**
40+
* Asserts that the argument passed in is neither undefined nor null.
41+
*/
42+
export function assertIsDefined<T>(arg: T | null | undefined): T {
43+
if (isUndefinedOrNull(arg)) {
44+
throw new Error('Assertion Failed: argument is undefined or null');
45+
}
46+
47+
return arg;
48+
}
49+
1850
export function createTerminateServer(server: http.Server) {
1951
const sockets: Record<number, net.Socket> = {};
2052
let socketCount = 0;
@@ -140,10 +172,10 @@ export function createServer(nonce: string) {
140172
}
141173
break;
142174
case '/':
143-
sendFile(res, getPathFromAmdModule(require, '../common/auth.html'), 'text/html; charset=utf-8');
175+
sendFile(res, path.join(__dirname, '../media/auth.html'), 'text/html; charset=utf-8');
144176
break;
145177
case '/auth.css':
146-
sendFile(res, getPathFromAmdModule(require, '../common/auth.css'), 'text/css; charset=utf-8');
178+
sendFile(res, path.join(__dirname, '../media/auth.css'), 'text/css; charset=utf-8');
147179
break;
148180
case '/callback':
149181
deferredCode.resolve(callback(nonce, reqUrl)

0 commit comments

Comments
 (0)