Skip to content

Commit 78e1ea2

Browse files
author
Rachel Macfarlane
authored
Adopt createAppUri API in authentication flow
1 parent 36737a7 commit 78e1ea2

File tree

3 files changed

+112
-56
lines changed

3 files changed

+112
-56
lines changed

src/authentication/githubServer.ts

Lines changed: 57 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,17 @@
1-
import * as vscode from 'vscode';
2-
import { IHostConfiguration, HostHelper } from './configuration';
31
import * as https from 'https';
4-
import { Base64 } from 'js-base64';
5-
import { parse } from 'query-string';
2+
import * as vscode from 'vscode';
63
import Logger from '../common/logger';
4+
import { agent } from '../common/net';
75
import { handler as uriHandler } from '../common/uri';
86
import { PromiseAdapter, promiseFromEvent } from '../common/utils';
9-
import { agent } from '../common/net';
10-
import { EXTENSION_ID } from '../constants';
11-
import { onDidChange as onKeychainDidChange, toCanonical, listHosts } from './keychain';
7+
import { HostHelper, IHostConfiguration } from './configuration';
8+
import { listHosts, onDidChange as onKeychainDidChange, toCanonical } from './keychain';
9+
import uuid = require('uuid');
1210

1311
const SCOPES: string = 'read:user user:email repo write:discussion';
1412
const GHE_OPTIONAL_SCOPES: { [key: string]: boolean } = {'write:discussion': true};
1513

16-
const AUTH_RELAY_SERVER = 'https://vscode-auth.github.com';
17-
const CALLBACK_PATH = '/did-authenticate';
18-
const CALLBACK_URI = `${vscode.env.uriScheme}://${EXTENSION_ID}${CALLBACK_PATH}`;
19-
const MAX_TOKEN_RESPONSE_AGE = 5 * (1000 * 60 /* minutes in ms */);
14+
const AUTH_RELAY_SERVER = 'vscode-auth.github.com';
2015

2116
export class GitHubManager {
2217
private _servers: Map<string, boolean> = new Map().set('github.com', true);
@@ -124,40 +119,54 @@ export class GitHubManager {
124119
}
125120
}
126121

127-
class ResponseExpired extends Error {
128-
get message() { return 'Token response expired'; }
129-
}
122+
const exchangeCodeForToken: (host: string, state: string) => PromiseAdapter<vscode.Uri, IHostConfiguration> =
123+
(host, state) => async (uri, resolve, reject) => {
124+
const query = parseQuery(uri);
125+
const code = query.code;
130126

131-
const SEPARATOR = '/', SEPARATOR_LEN = SEPARATOR.length;
132-
133-
/**
134-
* Hydrate and verify the signature of a message produced with `encode`
135-
*
136-
* Returns an object
137-
*
138-
* @param {string} signedMessage signed message produced by encode
139-
* @returns {any} decoded JSON data
140-
* @throws {SyntaxError} if the message was null or could not be parsed as JSON
141-
*/
142-
const decode = (signedMessage?: string): any => {
143-
if (!signedMessage) { throw new SyntaxError('Invalid encoding'); }
144-
const separatorIndex = signedMessage.indexOf(SEPARATOR);
145-
const message = signedMessage.substr(separatorIndex + SEPARATOR_LEN);
146-
return JSON.parse(Base64.decode(message));
147-
};
148-
149-
const verifyToken: (host: string) => PromiseAdapter<vscode.Uri, IHostConfiguration> =
150-
host => async (uri, resolve, reject) => {
151-
if (uri.path !== CALLBACK_PATH) { return; }
152-
const query = parse(uri.query);
153-
const state = decode(query.state as string);
154-
const { ts, access_token: token } = state.token;
155-
if (Date.now() - ts > MAX_TOKEN_RESPONSE_AGE) {
156-
return reject(new ResponseExpired);
127+
if (query.state !== state) {
128+
vscode.window.showInformationMessage('Sign in failed: Received bad state');
129+
reject('Received bad state');
130+
return;
157131
}
158-
resolve({ host, token });
132+
133+
const post = https.request({
134+
host: AUTH_RELAY_SERVER,
135+
path: `/token?code=${code}&state=${query.state}`,
136+
method: 'POST',
137+
headers: {
138+
Accept: 'application/json'
139+
}
140+
}, result => {
141+
const buffer: Buffer[] = [];
142+
result.on('data', (chunk: Buffer) => {
143+
buffer.push(chunk);
144+
});
145+
result.on('end', () => {
146+
if (result.statusCode === 200) {
147+
const json = JSON.parse(Buffer.concat(buffer).toString());
148+
resolve({ host, token: json.access_token });
149+
} else {
150+
vscode.window.showInformationMessage(`Sign in failed: ${result.statusMessage}`);
151+
reject(new Error(result.statusMessage));
152+
}
153+
});
154+
});
155+
156+
post.end();
157+
post.on('error', err => {
158+
reject(err);
159+
});
159160
};
160161

162+
function parseQuery(uri: vscode.Uri) {
163+
return uri.query.split('&').reduce((prev: any, current) => {
164+
const queryString = current.split('=');
165+
prev[queryString[0]] = queryString[1];
166+
return prev;
167+
}, {});
168+
}
169+
161170
const manuallyEnteredToken: (host: string) => PromiseAdapter<IHostConfiguration, IHostConfiguration> =
162171
host => (config: IHostConfiguration, resolve) =>
163172
config.host === toCanonical(host) && resolve(config);
@@ -172,14 +181,15 @@ export class GitHubServer {
172181
this.hostUri = vscode.Uri.parse(host);
173182
}
174183

175-
public login(): Promise<IHostConfiguration> {
184+
public async login(): Promise<IHostConfiguration> {
185+
const state = uuid();
186+
const callbackUri = await vscode.env.createAppUri({ payload: { path: '/did-authenticate' } });
187+
const uri = vscode.Uri.parse(`https://${AUTH_RELAY_SERVER}/authorize/?callbackUri=${encodeURIComponent(callbackUri.toString())}&scope=${SCOPES}&state=${state}&responseType=code`);
176188
const host = this.hostUri.toString();
177-
const uri = vscode.Uri.parse(
178-
`${AUTH_RELAY_SERVER}/authorize?authServer=${host}&callbackUri=${CALLBACK_URI}&scope=${SCOPES}`
179-
);
180-
vscode.commands.executeCommand('vscode.open', uri);
189+
190+
vscode.env.openExternal(uri);
181191
return Promise.race([
182-
promiseFromEvent(uriHandler.event, verifyToken(host)),
192+
promiseFromEvent(uriHandler.event, exchangeCodeForToken(host, state)),
183193
promiseFromEvent(onKeychainDidChange, manuallyEnteredToken(host))
184194
]);
185195
}

src/extension.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,22 @@
55
'use strict';
66

77
import * as vscode from 'vscode';
8+
import TelemetryReporter from 'vscode-extension-telemetry';
9+
import { Repository } from './api/api';
10+
import { ApiImpl } from './api/api1';
11+
import * as Keychain from './authentication/keychain';
812
import { migrateConfiguration } from './authentication/vsConfiguration';
9-
import { Resource } from './common/resources';
10-
import { ReviewManager } from './view/reviewManager';
1113
import { registerCommands } from './commands';
1214
import Logger from './common/logger';
13-
import { PullRequestManager } from './github/pullRequestManager';
15+
import { Resource } from './common/resources';
16+
import { handler as uriHandler } from './common/uri';
1417
import { formatError, onceEvent } from './common/utils';
18+
import { EXTENSION_ID } from './constants';
19+
import { PullRequestManager } from './github/pullRequestManager';
1520
import { registerBuiltinGitProvider, registerLiveShareGitProvider } from './gitProviders/api';
16-
import { handler as uriHandler } from './common/uri';
17-
import * as Keychain from './authentication/keychain';
1821
import { FileTypeDecorationProvider } from './view/fileTypeDecorationProvider';
1922
import { PullRequestsTreeDataProvider } from './view/prsTreeDataProvider';
20-
import { ApiImpl } from './api/api1';
21-
import { Repository } from './api/api';
22-
import TelemetryReporter from 'vscode-extension-telemetry';
23-
import { EXTENSION_ID } from './constants';
23+
import { ReviewManager } from './view/reviewManager';
2424

2525
const aiKey: string = 'AIF-d9b70cd4-b9f9-4d70-929b-a071c400b217';
2626

src/typings/vscode.proposed.d.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -783,4 +783,50 @@ declare module 'vscode' {
783783

784784
//#endregion
785785

786+
// #region Ben - extension auth flow (desktop+web)
787+
788+
export interface AppUriOptions {
789+
payload?: {
790+
path?: string;
791+
query?: string;
792+
fragment?: string;
793+
};
794+
}
795+
796+
export namespace env {
797+
798+
/**
799+
* Creates a Uri that - if opened in a browser - will result in a
800+
* registered [UriHandler](#UriHandler) to fire. The handler's
801+
* Uri will be configured with the path, query and fragment of
802+
* [AppUriOptions](#AppUriOptions) if provided, otherwise it will be empty.
803+
*
804+
* Extensions should not make any assumptions about the resulting
805+
* Uri and should not alter it in anyway. Rather, extensions can e.g.
806+
* use this Uri in an authentication flow, by adding the Uri as
807+
* callback query argument to the server to authenticate to.
808+
*
809+
* Note: If the server decides to add additional query parameters to the Uri
810+
* (e.g. a token or secret), it will appear in the Uri that is passed
811+
* to the [UriHandler](#UriHandler).
812+
*
813+
* **Example** of an authentication flow:
814+
* ```typescript
815+
* vscode.window.registerUriHandler({
816+
* handleUri(uri: vscode.Uri): vscode.ProviderResult<void> {
817+
* if (uri.path === '/did-authenticate') {
818+
* console.log(uri.toString());
819+
* }
820+
* }
821+
* });
822+
*
823+
* const callableUri = await vscode.env.createAppUri({ payload: { path: '/did-authenticate' } });
824+
* await vscode.env.openExternal(callableUri);
825+
* ```
826+
*/
827+
export function createAppUri(options?: AppUriOptions): Thenable<Uri>;
828+
}
829+
830+
//#endregion
831+
786832
}

0 commit comments

Comments
 (0)