Skip to content

Commit f67a8b7

Browse files
Use device flow over PAT when we are running in a server full environment but not in a supported uri (#139255)
* initial attempt * use github-authentication instead * rework error handling * update copy * explain why Workspace
1 parent e7b3724 commit f67a8b7

File tree

4 files changed

+131
-10
lines changed

4 files changed

+131
-10
lines changed

build/gulpfile.extensions.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const extensionsPath = path.join(path.dirname(__dirname), 'extensions');
2929
// ignore: ['**/out/**', '**/node_modules/**']
3030
// });
3131
const compilations = [
32+
'authentication-proxy/tsconfig.json',
3233
'configuration-editing/build/tsconfig.json',
3334
'configuration-editing/tsconfig.json',
3435
'css-language-features/client/tsconfig.json',
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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+
import { Uri } from 'vscode';
6+
7+
const VALID_DESKTOP_CALLBACK_SCHEMES = [
8+
'vscode',
9+
'vscode-insiders',
10+
'code-oss',
11+
'vscode-wsl',
12+
'vscode-exploration'
13+
];
14+
15+
// This comes from the GitHub Authentication server
16+
export function isSupportedEnvironment(url: Uri): boolean {
17+
return VALID_DESKTOP_CALLBACK_SCHEMES.includes(url.scheme) || url.authority.endsWith('vscode.dev') || url.authority.endsWith('github.dev');
18+
}

extensions/github-authentication/src/github.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,11 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid
4343
this._telemetryReporter = new ExperimentationTelemetry(context, new TelemetryReporter(name, version, aiKey));
4444

4545
if (this.type === AuthProviderType.github) {
46-
this._githubServer = new GitHubServer(this._logger, this._telemetryReporter);
46+
this._githubServer = new GitHubServer(
47+
// We only can use the Device Code flow when we are running with a remote extension host.
48+
context.extension.extensionKind === vscode.ExtensionKind.Workspace,
49+
this._logger,
50+
this._telemetryReporter);
4751
} else {
4852
this._githubServer = new GitHubEnterpriseServer(this._logger, this._telemetryReporter);
4953
}
@@ -216,7 +220,7 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid
216220
return session;
217221
} catch (e) {
218222
// If login was cancelled, do not notify user.
219-
if (e === 'Cancelled') {
223+
if (e === 'Cancelled' || e.message === 'Cancelled') {
220224
/* __GDPR__
221225
"loginCancelled" : { }
222226
*/

extensions/github-authentication/src/githubServer.ts

Lines changed: 106 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import { PromiseAdapter, promiseFromEvent } from './common/utils';
1111
import { ExperimentationTelemetry } from './experimentationService';
1212
import { AuthProviderType } from './github';
1313
import { Log } from './common/logger';
14+
import { isSupportedEnvironment } from './common/env';
1415

1516
const localize = nls.loadMessageBundle();
17+
const CLIENT_ID = '01ab8ac9400c4e429b23';
1618

1719
const NETWORK_ERROR = 'network error';
1820
const AUTH_RELAY_SERVER = 'vscode-auth.github.com';
@@ -45,6 +47,13 @@ export interface IGitHubServer extends vscode.Disposable {
4547
type: AuthProviderType;
4648
}
4749

50+
interface IGitHubDeviceCodeResponse {
51+
device_code: string;
52+
user_code: string;
53+
verification_uri: string;
54+
interval: number;
55+
}
56+
4857
async function getScopes(token: string, serverUri: vscode.Uri, logger: Log): Promise<string[]> {
4958
try {
5059
logger.info('Getting token scopes...');
@@ -105,7 +114,7 @@ export class GitHubServer implements IGitHubServer {
105114
private _disposable: vscode.Disposable;
106115
private _uriHandler = new UriEventHandler(this._logger);
107116

108-
constructor(private readonly _logger: Log, private readonly _telemetryReporter: ExperimentationTelemetry) {
117+
constructor(private readonly _supportDeviceCodeFlow: boolean, private readonly _logger: Log, private readonly _telemetryReporter: ExperimentationTelemetry) {
109118
this._disposable = vscode.Disposable.from(
110119
vscode.commands.registerCommand(this._statusBarCommandId, () => this.manuallyProvideUri()),
111120
vscode.window.registerUriHandler(this._uriHandler));
@@ -115,10 +124,6 @@ export class GitHubServer implements IGitHubServer {
115124
this._disposable.dispose();
116125
}
117126

118-
private isTestEnvironment(url: vscode.Uri): boolean {
119-
return /\.azurewebsites\.net$/.test(url.authority) || url.authority.startsWith('localhost:');
120-
}
121-
122127
// TODO@joaomoreno TODO@TylerLeonhardt
123128
private async isNoCorsEnvironment(): Promise<boolean> {
124129
const uri = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://vscode.github-authentication/dummy`));
@@ -130,9 +135,12 @@ export class GitHubServer implements IGitHubServer {
130135

131136
const callbackUri = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://vscode.github-authentication/did-authenticate`));
132137

133-
if (this.isTestEnvironment(callbackUri)) {
134-
const token = await vscode.window.showInputBox({ prompt: 'GitHub Personal Access Token', ignoreFocusOut: true });
135-
if (!token) { throw new Error('Sign in failed: No token provided'); }
138+
if (!isSupportedEnvironment(callbackUri)) {
139+
const token = this._supportDeviceCodeFlow
140+
? await this.doDeviceCodeFlow(scopes)
141+
: await vscode.window.showInputBox({ prompt: 'GitHub Personal Access Token', ignoreFocusOut: true });
142+
143+
if (!token) { throw new Error('No token provided'); }
136144

137145
const tokenScopes = await getScopes(token, this.getServerUri('/'), this._logger); // Example: ['repo', 'user']
138146
const scopesList = scopes.split(' '); // Example: 'read:user repo user:email'
@@ -187,6 +195,96 @@ export class GitHubServer implements IGitHubServer {
187195
});
188196
}
189197

198+
private async doDeviceCodeFlow(scopes: string): Promise<string> {
199+
// Get initial device code
200+
const uri = `https://github.com/login/device/code?client_id=${CLIENT_ID}&scope=${scopes}`;
201+
const result = await fetch(uri, {
202+
method: 'POST',
203+
headers: {
204+
Accept: 'application/json'
205+
}
206+
});
207+
if (!result.ok) {
208+
throw new Error(`Failed to get one-time code: ${await result.text()}`);
209+
}
210+
211+
const json = await result.json() as IGitHubDeviceCodeResponse;
212+
213+
await vscode.env.clipboard.writeText(json.user_code);
214+
215+
const modalResult = await vscode.window.showInformationMessage(
216+
localize('code.title', "Your Code: {0}", json.user_code),
217+
{
218+
modal: true,
219+
detail: localize('code.detail', "The above one-time code has been copied to your clipboard. To finish authenticating, paste it on GitHub.")
220+
}, 'Continue to GitHub');
221+
222+
if (modalResult !== 'Continue to GitHub') {
223+
throw new Error('Cancelled');
224+
}
225+
226+
const uriToOpen = await vscode.env.asExternalUri(vscode.Uri.parse(json.verification_uri));
227+
await vscode.env.openExternal(uriToOpen);
228+
229+
return await vscode.window.withProgress<string>({
230+
location: vscode.ProgressLocation.Notification,
231+
cancellable: true,
232+
title: localize(
233+
'progress',
234+
"Open [{0}]({0}) in a new tab and paste your one-time code: {1}",
235+
json.verification_uri,
236+
json.user_code)
237+
}, async (_, token) => {
238+
return await this.waitForDeviceCodeAccessToken(json, token);
239+
});
240+
}
241+
242+
private async waitForDeviceCodeAccessToken(
243+
json: IGitHubDeviceCodeResponse,
244+
token: vscode.CancellationToken
245+
): Promise<string> {
246+
247+
const refreshTokenUri = `https://github.com/login/oauth/access_token?client_id=${CLIENT_ID}&device_code=${json.device_code}&grant_type=urn:ietf:params:oauth:grant-type:device_code`;
248+
249+
// Try for 2 minutes
250+
const attempts = 120 / json.interval;
251+
for (let i = 0; i < attempts; i++) {
252+
await new Promise(resolve => setTimeout(resolve, json.interval * 1000));
253+
if (token.isCancellationRequested) {
254+
throw new Error('Cancelled');
255+
}
256+
let accessTokenResult;
257+
try {
258+
accessTokenResult = await fetch(refreshTokenUri, {
259+
method: 'POST',
260+
headers: {
261+
Accept: 'application/json'
262+
}
263+
});
264+
} catch {
265+
continue;
266+
}
267+
268+
if (!accessTokenResult.ok) {
269+
continue;
270+
}
271+
272+
const accessTokenJson = await accessTokenResult.json();
273+
274+
if (accessTokenJson.error === 'authorization_pending') {
275+
continue;
276+
}
277+
278+
if (accessTokenJson.error) {
279+
throw new Error(accessTokenJson.error_description);
280+
}
281+
282+
return accessTokenJson.access_token;
283+
}
284+
285+
throw new Error('Cancelled');
286+
}
287+
190288
private exchangeCodeForToken: (scopes: string) => PromiseAdapter<vscode.Uri, string> =
191289
(scopes) => async (uri, resolve, reject) => {
192290
const query = parseQuery(uri);

0 commit comments

Comments
 (0)