chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:42 +08:00
commit 2d485ba600
8326 changed files with 2641263 additions and 0 deletions
@@ -0,0 +1 @@
src/common/config.json
+2
View File
@@ -0,0 +1,2 @@
legacy-peer-deps="true"
timeout=180000
@@ -0,0 +1,9 @@
.gitignore
src/**
!src/common/config.json
out/**
build/**
extension.webpack.config.js
extension-browser.webpack.config.js
tsconfig.json
package-lock.json
@@ -0,0 +1,7 @@
# GitHub Authentication for Visual Studio Code
**Notice:** This extension is bundled with Visual Studio Code. It can be disabled but not uninstalled.
## Features
This extension provides support for authenticating to GitHub. It registers the `github` Authentication Provider that can be leveraged by other extensions. This also provides the GitHub authentication used by Settings Sync.
@@ -0,0 +1,28 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
//@ts-check
'use strict';
const path = require('path');
const withBrowserDefaults = require('../shared.webpack.config').browser;
module.exports = withBrowserDefaults({
context: __dirname,
node: false,
entry: {
extension: './src/extension.ts',
},
resolve: {
alias: {
'uuid': path.resolve(__dirname, 'node_modules/uuid/dist/esm-browser/index.js'),
'./node/authServer': path.resolve(__dirname, 'src/browser/authServer'),
'./node/crypto': path.resolve(__dirname, 'src/browser/crypto'),
'./node/fetch': path.resolve(__dirname, 'src/browser/fetch'),
'./node/buffer': path.resolve(__dirname, 'src/browser/buffer'),
}
}
});
@@ -0,0 +1,17 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
//@ts-check
'use strict';
const withDefaults = require('../shared.webpack.config');
module.exports = withDefaults({
context: __dirname,
entry: {
extension: './src/extension.ts',
},
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -0,0 +1,37 @@
<!-- Copyright (C) Microsoft Corporation. All rights reserved. -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>GitHub Authentication - Sign In</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" media="screen" href="auth.css" />
</head>
<body>
<a class="branding" href="https://code.visualstudio.com/">
Visual Studio Code
</a>
<div class="message-container">
<div class="message">
You are signed in now and can close this page.
</div>
<div class="error-message">
An error occurred while signing in:
<div class="error-text"></div>
</div>
</div>
<script>
var search = window.location.search;
var error = (/[?&^]error=([^&]+)/.exec(search) || [])[1];
if (error) {
document.querySelector('.error-text')
.textContent = decodeURIComponent(error);
document.querySelector('body')
.classList.add('error');
}
</script>
</body>
</html>
+311
View File
@@ -0,0 +1,311 @@
{
"name": "github-authentication",
"version": "0.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "github-authentication",
"version": "0.0.2",
"license": "MIT",
"dependencies": {
"@vscode/extension-telemetry": "^0.9.8",
"node-fetch": "2.6.7",
"vscode-tas-client": "^0.1.84"
},
"devDependencies": {
"@types/mocha": "^9.1.1",
"@types/node": "20.x",
"@types/node-fetch": "^2.5.7"
},
"engines": {
"vscode": "^1.41.0"
}
},
"node_modules/@microsoft/1ds-core-js": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/@microsoft/1ds-core-js/-/1ds-core-js-4.3.4.tgz",
"integrity": "sha512-3gbDUQgAO8EoyQTNcAEkxpuPnioC0May13P1l1l0NKZ128L9Ts/sj8QsfwCRTjHz0HThlA+4FptcAJXNYUy3rg==",
"license": "MIT",
"dependencies": {
"@microsoft/applicationinsights-core-js": "3.3.4",
"@microsoft/applicationinsights-shims": "3.0.1",
"@microsoft/dynamicproto-js": "^2.0.3",
"@nevware21/ts-async": ">= 0.5.2 < 2.x",
"@nevware21/ts-utils": ">= 0.11.3 < 2.x"
}
},
"node_modules/@microsoft/1ds-post-js": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/@microsoft/1ds-post-js/-/1ds-post-js-4.3.4.tgz",
"integrity": "sha512-nlKjWricDj0Tn68Dt0P8lX9a+X7LYrqJ6/iSfQwMfDhRIGLqW+wxx8gxS+iGWC/oc8zMQAeiZaemUpCwQcwpRQ==",
"license": "MIT",
"dependencies": {
"@microsoft/1ds-core-js": "4.3.4",
"@microsoft/applicationinsights-shims": "3.0.1",
"@microsoft/dynamicproto-js": "^2.0.3",
"@nevware21/ts-async": ">= 0.5.2 < 2.x",
"@nevware21/ts-utils": ">= 0.11.3 < 2.x"
}
},
"node_modules/@microsoft/applicationinsights-channel-js": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-channel-js/-/applicationinsights-channel-js-3.3.4.tgz",
"integrity": "sha512-Z4nrxYwGKP9iyrYtm7iPQXVOFy4FsEsX0nDKkAi96Qpgw+vEh6NH4ORxMMuES0EollBQ3faJyvYCwckuCVIj0g==",
"license": "MIT",
"dependencies": {
"@microsoft/applicationinsights-common": "3.3.4",
"@microsoft/applicationinsights-core-js": "3.3.4",
"@microsoft/applicationinsights-shims": "3.0.1",
"@microsoft/dynamicproto-js": "^2.0.3",
"@nevware21/ts-async": ">= 0.5.2 < 2.x",
"@nevware21/ts-utils": ">= 0.11.3 < 2.x"
},
"peerDependencies": {
"tslib": ">= 1.0.0"
}
},
"node_modules/@microsoft/applicationinsights-common": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-common/-/applicationinsights-common-3.3.4.tgz",
"integrity": "sha512-4ms16MlIvcP4WiUPqopifNxcWCcrXQJ2ADAK/75uok2mNQe6ZNRsqb/P+pvhUxc8A5HRlvoXPP1ptDSN5Girgw==",
"license": "MIT",
"dependencies": {
"@microsoft/applicationinsights-core-js": "3.3.4",
"@microsoft/applicationinsights-shims": "3.0.1",
"@microsoft/dynamicproto-js": "^2.0.3",
"@nevware21/ts-utils": ">= 0.11.3 < 2.x"
},
"peerDependencies": {
"tslib": ">= 1.0.0"
}
},
"node_modules/@microsoft/applicationinsights-core-js": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-core-js/-/applicationinsights-core-js-3.3.4.tgz",
"integrity": "sha512-MummANF0mgKIkdvVvfmHQTBliK114IZLRhTL0X0Ep+zjDwWMHqYZgew0nlFKAl6ggu42abPZFK5afpE7qjtYJA==",
"license": "MIT",
"dependencies": {
"@microsoft/applicationinsights-shims": "3.0.1",
"@microsoft/dynamicproto-js": "^2.0.3",
"@nevware21/ts-async": ">= 0.5.2 < 2.x",
"@nevware21/ts-utils": ">= 0.11.3 < 2.x"
},
"peerDependencies": {
"tslib": ">= 1.0.0"
}
},
"node_modules/@microsoft/applicationinsights-shims": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-shims/-/applicationinsights-shims-3.0.1.tgz",
"integrity": "sha512-DKwboF47H1nb33rSUfjqI6ryX29v+2QWcTrRvcQDA32AZr5Ilkr7whOOSsD1aBzwqX0RJEIP1Z81jfE3NBm/Lg==",
"license": "MIT",
"dependencies": {
"@nevware21/ts-utils": ">= 0.9.4 < 2.x"
}
},
"node_modules/@microsoft/applicationinsights-web-basic": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-web-basic/-/applicationinsights-web-basic-3.3.4.tgz",
"integrity": "sha512-OpEPXr8vU/t/M8T9jvWJzJx/pCyygIiR1nGM/2PTde0wn7anl71Gxl5fWol7K/WwFEORNjkL3CEyWOyDc+28AA==",
"license": "MIT",
"dependencies": {
"@microsoft/applicationinsights-channel-js": "3.3.4",
"@microsoft/applicationinsights-common": "3.3.4",
"@microsoft/applicationinsights-core-js": "3.3.4",
"@microsoft/applicationinsights-shims": "3.0.1",
"@microsoft/dynamicproto-js": "^2.0.3",
"@nevware21/ts-async": ">= 0.5.2 < 2.x",
"@nevware21/ts-utils": ">= 0.11.3 < 2.x"
},
"peerDependencies": {
"tslib": ">= 1.0.0"
}
},
"node_modules/@microsoft/dynamicproto-js": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@microsoft/dynamicproto-js/-/dynamicproto-js-2.0.3.tgz",
"integrity": "sha512-JTWTU80rMy3mdxOjjpaiDQsTLZ6YSGGqsjURsY6AUQtIj0udlF/jYmhdLZu8693ZIC0T1IwYnFa0+QeiMnziBA==",
"license": "MIT",
"dependencies": {
"@nevware21/ts-utils": ">= 0.10.4 < 2.x"
}
},
"node_modules/@nevware21/ts-async": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/@nevware21/ts-async/-/ts-async-0.5.4.tgz",
"integrity": "sha512-IBTyj29GwGlxfzXw2NPnzty+w0Adx61Eze1/lknH/XIVdxtF9UnOpk76tnrHXWa6j84a1RR9hsOcHQPFv9qJjA==",
"license": "MIT",
"dependencies": {
"@nevware21/ts-utils": ">= 0.11.6 < 2.x"
}
},
"node_modules/@nevware21/ts-utils": {
"version": "0.11.6",
"resolved": "https://registry.npmjs.org/@nevware21/ts-utils/-/ts-utils-0.11.6.tgz",
"integrity": "sha512-OUUJTh3fnaUSzg9DEHgv3d7jC+DnPL65mIO7RaR+jWve7+MmcgIvF79gY97DPQ4frH+IpNR78YAYd/dW4gK3kg==",
"license": "MIT"
},
"node_modules/@types/mocha": {
"version": "9.1.1",
"resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-9.1.1.tgz",
"integrity": "sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==",
"dev": true
},
"node_modules/@types/node": {
"version": "20.11.24",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.24.tgz",
"integrity": "sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@types/node-fetch": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.7.tgz",
"integrity": "sha512-o2WVNf5UhWRkxlf6eq+jMZDu7kjgpgJfl4xVNlvryc95O/6F2ld8ztKX+qu+Rjyet93WAWm5LjeX9H5FGkODvw==",
"dev": true,
"dependencies": {
"@types/node": "*",
"form-data": "^3.0.0"
}
},
"node_modules/@vscode/extension-telemetry": {
"version": "0.9.8",
"resolved": "https://registry.npmjs.org/@vscode/extension-telemetry/-/extension-telemetry-0.9.8.tgz",
"integrity": "sha512-7YcKoUvmHlIB8QYCE4FNzt3ErHi9gQPhdCM3ZWtpw1bxPT0I+lMdx52KHlzTNoJzQ2NvMX7HyzyDwBEiMgTrWQ==",
"license": "MIT",
"dependencies": {
"@microsoft/1ds-core-js": "^4.3.4",
"@microsoft/1ds-post-js": "^4.3.4",
"@microsoft/applicationinsights-web-basic": "^3.3.4"
},
"engines": {
"vscode": "^1.75.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k= sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk= sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/form-data": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.0.tgz",
"integrity": "sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==",
"dev": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/mime-db": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz",
"integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==",
"dev": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.27",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz",
"integrity": "sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==",
"dev": true,
"dependencies": {
"mime-db": "1.44.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/node-fetch": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
"integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/tas-client": {
"version": "0.2.33",
"resolved": "https://registry.npmjs.org/tas-client/-/tas-client-0.2.33.tgz",
"integrity": "sha512-V+uqV66BOQnWxvI6HjDnE4VkInmYZUQ4dgB7gzaDyFyFSK1i1nF/j7DpS9UbQAgV9NaF1XpcyuavnM1qOeiEIg=="
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o= sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
},
"node_modules/vscode-tas-client": {
"version": "0.1.84",
"resolved": "https://registry.npmjs.org/vscode-tas-client/-/vscode-tas-client-0.1.84.tgz",
"integrity": "sha512-rUTrUopV+70hvx1hW5ebdw1nd6djxubkLvVxjGdyD/r5v/wcVF41LIfiAtbm5qLZDtQdsMH1IaCuDoluoIa88w==",
"dependencies": {
"tas-client": "0.2.33"
},
"engines": {
"vscode": "^1.85.0"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE= sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0= sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
}
}
}
@@ -0,0 +1,77 @@
{
"name": "github-authentication",
"displayName": "%displayName%",
"description": "%description%",
"publisher": "vscode",
"license": "MIT",
"version": "0.0.2",
"engines": {
"vscode": "^1.41.0"
},
"icon": "images/icon.png",
"categories": [
"Other"
],
"api": "none",
"extensionKind": [
"ui",
"workspace"
],
"activationEvents": [],
"capabilities": {
"virtualWorkspaces": true,
"untrustedWorkspaces": {
"supported": "limited",
"restrictedConfigurations": [
"github-enterprise.uri"
]
}
},
"contributes": {
"authentication": [
{
"label": "GitHub",
"id": "github"
},
{
"label": "GitHub Enterprise Server",
"id": "github-enterprise"
}
],
"configuration": [{
"title": "%config.github-enterprise.title%",
"properties": {
"github-enterprise.uri": {
"type": "string",
"markdownDescription": "%config.github-enterprise.uri.description%",
"pattern": "^(?:$|(https?)://(?!github\\.com).*)"
}
}
}
]
},
"aiKey": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255",
"main": "./out/extension.js",
"browser": "./dist/browser/extension.js",
"scripts": {
"compile": "gulp compile-extension:github-authentication",
"compile-web": "npx webpack-cli --config extension-browser.webpack.config --mode none",
"watch": "gulp watch-extension:github-authentication",
"watch-web": "npx webpack-cli --config extension-browser.webpack.config --mode none --watch --info-verbosity verbose",
"vscode:prepublish": "npm run compile"
},
"dependencies": {
"node-fetch": "2.6.7",
"@vscode/extension-telemetry": "^0.9.8",
"vscode-tas-client": "^0.1.84"
},
"devDependencies": {
"@types/mocha": "^9.1.1",
"@types/node": "20.x",
"@types/node-fetch": "^2.5.7"
},
"repository": {
"type": "git",
"url": "https://github.com/microsoft/vscode.git"
}
}
@@ -0,0 +1,6 @@
{
"displayName": "GitHub Authentication",
"description": "GitHub Authentication Provider",
"config.github-enterprise.title": "GHE.com & GitHub Enterprise Server Authentication",
"config.github-enterprise.uri.description": "The URI for your GHE.com or GitHub Enterprise Server instance.\n\nExamples:\n* GHE.com: `https://octocat.ghe.com`\n* GitHub Enterprise Server: `https://github.octocat.com`\n\n> **Note:** This should _not_ be set to a GitHub.com URI. If your account exists on GitHub.com or is a GitHub Enterprise Managed User, you do not need any additional configuration and can simply log in to GitHub."
}
@@ -0,0 +1,12 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
export function startServer(_: any): any {
throw new Error('Not implemented');
}
export function createServer(_: any): any {
throw new Error('Not implemented');
}
@@ -0,0 +1,8 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
export function base64Encode(text: string): string {
return btoa(text);
}
@@ -0,0 +1,6 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
export const crypto = globalThis.crypto;
@@ -0,0 +1,6 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
export const fetching = fetch;
@@ -0,0 +1,38 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Uri } from 'vscode';
import { AuthProviderType } from '../github';
const VALID_DESKTOP_CALLBACK_SCHEMES = [
'vscode',
'vscode-insiders',
// On Windows, some browsers don't seem to redirect back to OSS properly.
// As a result, you get stuck in the auth flow. We exclude this from the
// list until we can figure out a way to fix this behavior in browsers.
// 'code-oss',
'vscode-wsl',
'vscode-exploration'
];
export function isSupportedClient(uri: Uri): boolean {
return (
VALID_DESKTOP_CALLBACK_SCHEMES.includes(uri.scheme) ||
// vscode.dev & insiders.vscode.dev
/(?:^|\.)vscode\.dev$/.test(uri.authority) ||
// github.dev & codespaces
/(?:^|\.)github\.dev$/.test(uri.authority)
);
}
export function isSupportedTarget(type: AuthProviderType, gheUri?: Uri): boolean {
return (
type === AuthProviderType.github ||
isHostedGitHubEnterprise(gheUri!)
);
}
export function isHostedGitHubEnterprise(uri: Uri): boolean {
return /\.ghe\.com$/.test(uri.authority);
}
@@ -0,0 +1,14 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
export const TIMED_OUT_ERROR = 'Timed out';
// These error messages are internal and should not be shown to the user in any way.
export const USER_CANCELLATION_ERROR = 'User Cancelled';
export const NETWORK_ERROR = 'network error';
// This is the error message that we throw if the login was cancelled for any reason. Extensions
// calling `getSession` can handle this error to know that the user cancelled the login.
export const CANCELLATION_ERROR = 'Cancelled';
@@ -0,0 +1,96 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import TelemetryReporter from '@vscode/extension-telemetry';
import { getExperimentationService, IExperimentationService, IExperimentationTelemetry, TargetPopulation } from 'vscode-tas-client';
export class ExperimentationTelemetry implements IExperimentationTelemetry {
private sharedProperties: Record<string, string> = {};
private experimentationServicePromise: Promise<IExperimentationService> | undefined;
constructor(private readonly context: vscode.ExtensionContext, private baseReporter: TelemetryReporter) { }
private async createExperimentationService(): Promise<IExperimentationService> {
let targetPopulation: TargetPopulation;
switch (vscode.env.uriScheme) {
case 'vscode':
targetPopulation = TargetPopulation.Public;
break;
case 'vscode-insiders':
targetPopulation = TargetPopulation.Insiders;
break;
case 'vscode-exploration':
targetPopulation = TargetPopulation.Internal;
break;
case 'code-oss':
targetPopulation = TargetPopulation.Team;
break;
default:
targetPopulation = TargetPopulation.Public;
break;
}
const id = this.context.extension.id;
const version = this.context.extension.packageJSON.version;
const experimentationService = getExperimentationService(id, version, targetPopulation, this, this.context.globalState);
await experimentationService.initialFetch;
return experimentationService;
}
/**
* @returns A promise that you shouldn't need to await because this is just telemetry.
*/
async sendTelemetryEvent(eventName: string, properties?: Record<string, string>, measurements?: Record<string, number>) {
if (!this.experimentationServicePromise) {
this.experimentationServicePromise = this.createExperimentationService();
}
await this.experimentationServicePromise;
this.baseReporter.sendTelemetryEvent(
eventName,
{
...this.sharedProperties,
...properties,
},
measurements,
);
}
/**
* @returns A promise that you shouldn't need to await because this is just telemetry.
*/
async sendTelemetryErrorEvent(
eventName: string,
properties?: Record<string, string>,
_measurements?: Record<string, number>
) {
if (!this.experimentationServicePromise) {
this.experimentationServicePromise = this.createExperimentationService();
}
await this.experimentationServicePromise;
this.baseReporter.sendTelemetryErrorEvent(eventName, {
...this.sharedProperties,
...properties,
});
}
setSharedProperty(name: string, value: string): void {
this.sharedProperties[name] = value;
}
postEvent(eventName: string, props: Map<string, string>): void {
const event: Record<string, string> = {};
for (const [key, value] of props) {
event[key] = value;
}
this.sendTelemetryEvent(eventName, event);
}
dispose(): Promise<any> {
return this.baseReporter.dispose();
}
}
@@ -0,0 +1,48 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { Log } from './logger';
export class Keychain {
constructor(
private readonly context: vscode.ExtensionContext,
private readonly serviceId: string,
private readonly Logger: Log
) { }
async setToken(token: string): Promise<void> {
try {
return await this.context.secrets.store(this.serviceId, token);
} catch (e) {
// Ignore
this.Logger.error(`Setting token failed: ${e}`);
}
}
async getToken(): Promise<string | null | undefined> {
try {
const secret = await this.context.secrets.get(this.serviceId);
if (secret && secret !== '[]') {
this.Logger.trace('Token acquired from secret storage.');
}
return secret;
} catch (e) {
// Ignore
this.Logger.error(`Getting token failed: ${e}`);
return Promise.resolve(undefined);
}
}
async deleteToken(): Promise<void> {
try {
return await this.context.secrets.delete(this.serviceId);
} catch (e) {
// Ignore
this.Logger.error(`Deleting token failed: ${e}`);
return Promise.resolve(undefined);
}
}
}
@@ -0,0 +1,32 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { AuthProviderType } from '../github';
export class Log {
private output: vscode.LogOutputChannel;
constructor(private readonly type: AuthProviderType) {
const friendlyName = this.type === AuthProviderType.github ? 'GitHub' : 'GitHub Enterprise';
this.output = vscode.window.createOutputChannel(`${friendlyName} Authentication`, { log: true });
}
public trace(message: string): void {
this.output.trace(message);
}
public info(message: string): void {
this.output.info(message);
}
public error(message: string): void {
this.output.error(message);
}
public warn(message: string): void {
this.output.warn(message);
}
}
@@ -0,0 +1,118 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { EventEmitter, Event, Disposable } from 'vscode';
export function filterEvent<T>(event: Event<T>, filter: (e: T) => boolean): Event<T> {
return (listener, thisArgs = null, disposables?) => event(e => filter(e) && listener.call(thisArgs, e), null, disposables);
}
export function onceEvent<T>(event: Event<T>): Event<T> {
return (listener, thisArgs = null, disposables?) => {
const result = event(e => {
result.dispose();
return listener.call(thisArgs, e);
}, null, disposables);
return result;
};
}
export interface PromiseAdapter<T, U> {
(
value: T,
resolve:
(value: U | PromiseLike<U>) => void,
reject:
(reason: any) => void
): any;
}
const passthrough = (value: any, resolve: (value?: any) => void) => resolve(value);
/**
* Return a promise that resolves with the next emitted event, or with some future
* event as decided by an adapter.
*
* If specified, the adapter is a function that will be called with
* `(event, resolve, reject)`. It will be called once per event until it resolves or
* rejects.
*
* The default adapter is the passthrough function `(value, resolve) => resolve(value)`.
*
* @param event the event
* @param adapter controls resolution of the returned promise
* @returns a promise that resolves or rejects as specified by the adapter
*/
export function promiseFromEvent<T, U>(
event: Event<T>,
adapter: PromiseAdapter<T, U> = passthrough): { promise: Promise<U>; cancel: EventEmitter<void> } {
let subscription: Disposable;
const cancel = new EventEmitter<void>();
return {
promise: new Promise<U>((resolve, reject) => {
cancel.event(_ => reject('Cancelled'));
subscription = event((value: T) => {
try {
Promise.resolve(adapter(value, resolve, reject))
.catch(reject);
} catch (error) {
reject(error);
}
});
}).then(
(result: U) => {
subscription.dispose();
return result;
},
error => {
subscription.dispose();
throw error;
}
),
cancel
};
}
export function arrayEquals<T>(one: ReadonlyArray<T> | undefined, other: ReadonlyArray<T> | undefined, itemEquals: (a: T, b: T) => boolean = (a, b) => a === b): boolean {
if (one === other) {
return true;
}
if (!one || !other) {
return false;
}
if (one.length !== other.length) {
return false;
}
for (let i = 0, len = one.length; i < len; i++) {
if (!itemEquals(one[i], other[i])) {
return false;
}
}
return true;
}
export class StopWatch {
private _startTime: number = Date.now();
private _stopTime: number = -1;
public stop(): void {
this._stopTime = Date.now();
}
public elapsed(): number {
if (this._stopTime !== -1) {
return this._stopTime - this._startTime;
}
return Date.now() - this._startTime;
}
}
@@ -0,0 +1,19 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
export interface IConfig {
// The client ID of the GitHub OAuth app
gitHubClientId: string;
gitHubClientSecret?: string;
}
// For easy access to mixin client ID and secret
//
// NOTE: GitHub client secrets cannot be secured when running in a native client so in other words, the client secret is
// not really a secret... so we allow the client secret in code. It is brought in before we publish VS Code. Reference:
// https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/best-practices-for-creating-an-oauth-app#client-secrets
export const Config: IConfig = {
gitHubClientId: '01ab8ac9400c4e429b23'
};
@@ -0,0 +1,46 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { GitHubAuthenticationProvider, UriEventHandler } from './github';
function initGHES(context: vscode.ExtensionContext, uriHandler: UriEventHandler) {
const settingValue = vscode.workspace.getConfiguration().get<string>('github-enterprise.uri');
if (!settingValue) {
return undefined;
}
// validate user value
let uri: vscode.Uri;
try {
uri = vscode.Uri.parse(settingValue, true);
} catch (e) {
vscode.window.showErrorMessage(vscode.l10n.t('GitHub Enterprise Server URI is not a valid URI: {0}', e.message ?? e));
return;
}
const githubEnterpriseAuthProvider = new GitHubAuthenticationProvider(context, uriHandler, uri);
context.subscriptions.push(githubEnterpriseAuthProvider);
return githubEnterpriseAuthProvider;
}
export function activate(context: vscode.ExtensionContext) {
const uriHandler = new UriEventHandler();
context.subscriptions.push(uriHandler);
context.subscriptions.push(vscode.window.registerUriHandler(uriHandler));
context.subscriptions.push(new GitHubAuthenticationProvider(context, uriHandler));
let githubEnterpriseAuthProvider: GitHubAuthenticationProvider | undefined = initGHES(context, uriHandler);
context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(async e => {
if (e.affectsConfiguration('github-enterprise.uri')) {
if (vscode.workspace.getConfiguration().get<string>('github-enterprise.uri')) {
githubEnterpriseAuthProvider?.dispose();
githubEnterpriseAuthProvider = initGHES(context, uriHandler);
}
}
}));
}
@@ -0,0 +1,513 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as path from 'path';
import { ProgressLocation, Uri, commands, env, l10n, window } from 'vscode';
import { Log } from './common/logger';
import { Config } from './config';
import { UriEventHandler } from './github';
import { fetching } from './node/fetch';
import { LoopbackAuthServer } from './node/authServer';
import { promiseFromEvent } from './common/utils';
import { isHostedGitHubEnterprise } from './common/env';
import { NETWORK_ERROR, TIMED_OUT_ERROR, USER_CANCELLATION_ERROR } from './common/errors';
interface IGitHubDeviceCodeResponse {
device_code: string;
user_code: string;
verification_uri: string;
interval: number;
}
interface IFlowOptions {
// GitHub.com
readonly supportsGitHubDotCom: boolean;
// A GitHub Enterprise Server that is hosted by an organization
readonly supportsGitHubEnterpriseServer: boolean;
// A GitHub Enterprise Server that is hosted by GitHub for an organization
readonly supportsHostedGitHubEnterprise: boolean;
// Runtimes - there are constraints on which runtimes support which flows
readonly supportsWebWorkerExtensionHost: boolean;
readonly supportsRemoteExtensionHost: boolean;
// Clients - see `isSupportedClient` in `common/env.ts` for what constitutes a supported client
readonly supportsSupportedClients: boolean;
readonly supportsUnsupportedClients: boolean;
// Configurations - some flows require a client secret
readonly supportsNoClientSecret: boolean;
}
export const enum GitHubTarget {
DotCom,
Enterprise,
HostedEnterprise
}
export const enum ExtensionHost {
WebWorker,
Remote,
Local
}
export interface IFlowQuery {
target: GitHubTarget;
extensionHost: ExtensionHost;
isSupportedClient: boolean;
}
interface IFlowTriggerOptions {
scopes: string;
baseUri: Uri;
logger: Log;
redirectUri: Uri;
nonce: string;
callbackUri: Uri;
uriHandler: UriEventHandler;
enterpriseUri?: Uri;
existingLogin?: string;
}
interface IFlow {
label: string;
options: IFlowOptions;
trigger(options: IFlowTriggerOptions): Promise<string>;
}
async function exchangeCodeForToken(
logger: Log,
endpointUri: Uri,
redirectUri: Uri,
code: string,
enterpriseUri?: Uri
): Promise<string> {
logger.info('Exchanging code for token...');
const clientSecret = Config.gitHubClientSecret;
if (!clientSecret) {
throw new Error('No client secret configured for GitHub authentication.');
}
const body = new URLSearchParams([
['code', code],
['client_id', Config.gitHubClientId],
['redirect_uri', redirectUri.toString(true)],
['client_secret', clientSecret]
]);
if (enterpriseUri) {
body.append('github_enterprise', enterpriseUri.toString(true));
}
const result = await fetching(endpointUri.toString(true), {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: body.toString()
});
if (result.ok) {
const json = await result.json();
logger.info('Token exchange success!');
return json.access_token;
} else {
const text = await result.text();
const error = new Error(text);
error.name = 'GitHubTokenExchangeError';
throw error;
}
}
const allFlows: IFlow[] = [
new class UrlHandlerFlow implements IFlow {
label = l10n.t('url handler');
options: IFlowOptions = {
supportsGitHubDotCom: true,
// Supporting GHES would be challenging because different versions
// used a different client ID. We could try to detect the version
// and use the right one, but that's a lot of work when we have
// other flows that work well.
supportsGitHubEnterpriseServer: false,
supportsHostedGitHubEnterprise: true,
supportsRemoteExtensionHost: true,
supportsWebWorkerExtensionHost: true,
// exchanging a code for a token requires a client secret
supportsNoClientSecret: false,
supportsSupportedClients: true,
supportsUnsupportedClients: false
};
async trigger({
scopes,
baseUri,
redirectUri,
logger,
nonce,
callbackUri,
uriHandler,
enterpriseUri,
existingLogin
}: IFlowTriggerOptions): Promise<string> {
logger.info(`Trying without local server... (${scopes})`);
return await window.withProgress<string>({
location: ProgressLocation.Notification,
title: l10n.t({
message: 'Signing in to {0}...',
args: [baseUri.authority],
comment: ['The {0} will be a url, e.g. github.com']
}),
cancellable: true
}, async (_, token) => {
const promise = uriHandler.waitForCode(logger, scopes, nonce, token);
const searchParams = new URLSearchParams([
['client_id', Config.gitHubClientId],
['redirect_uri', redirectUri.toString(true)],
['scope', scopes],
['state', encodeURIComponent(callbackUri.toString(true))]
]);
if (existingLogin) {
searchParams.append('login', existingLogin);
} else {
searchParams.append('prompt', 'select_account');
}
// The extra toString, parse is apparently needed for env.openExternal
// to open the correct URL.
const uri = Uri.parse(baseUri.with({
path: '/login/oauth/authorize',
query: searchParams.toString()
}).toString(true));
await env.openExternal(uri);
const code = await promise;
const proxyEndpoints: { [providerId: string]: string } | undefined = await commands.executeCommand('workbench.getCodeExchangeProxyEndpoints');
const endpointUrl = proxyEndpoints?.github
? Uri.parse(`${proxyEndpoints.github}login/oauth/access_token`)
: baseUri.with({ path: '/login/oauth/access_token' });
const accessToken = await exchangeCodeForToken(logger, endpointUrl, redirectUri, code, enterpriseUri);
return accessToken;
});
}
},
new class LocalServerFlow implements IFlow {
label = l10n.t('local server');
options: IFlowOptions = {
supportsGitHubDotCom: true,
// Supporting GHES would be challenging because different versions
// used a different client ID. We could try to detect the version
// and use the right one, but that's a lot of work when we have
// other flows that work well.
supportsGitHubEnterpriseServer: false,
supportsHostedGitHubEnterprise: true,
// Opening a port on the remote side can't be open in the browser on
// the client side so this flow won't work in remote extension hosts
supportsRemoteExtensionHost: false,
// Web worker can't open a port to listen for the redirect
supportsWebWorkerExtensionHost: false,
// exchanging a code for a token requires a client secret
supportsNoClientSecret: false,
supportsSupportedClients: true,
supportsUnsupportedClients: true
};
async trigger({
scopes,
baseUri,
redirectUri,
logger,
enterpriseUri,
existingLogin
}: IFlowTriggerOptions): Promise<string> {
logger.info(`Trying with local server... (${scopes})`);
return await window.withProgress<string>({
location: ProgressLocation.Notification,
title: l10n.t({
message: 'Signing in to {0}...',
args: [baseUri.authority],
comment: ['The {0} will be a url, e.g. github.com']
}),
cancellable: true
}, async (_, token) => {
const searchParams = new URLSearchParams([
['client_id', Config.gitHubClientId],
['redirect_uri', redirectUri.toString(true)],
['scope', scopes],
]);
if (existingLogin) {
searchParams.append('login', existingLogin);
} else {
searchParams.append('prompt', 'select_account');
}
const loginUrl = baseUri.with({
path: '/login/oauth/authorize',
query: searchParams.toString()
});
const server = new LoopbackAuthServer(path.join(__dirname, '../media'), loginUrl.toString(true));
const port = await server.start();
let codeToExchange;
try {
env.openExternal(Uri.parse(`http://127.0.0.1:${port}/signin?nonce=${encodeURIComponent(server.nonce)}`));
const { code } = await Promise.race([
server.waitForOAuthResponse(),
new Promise<any>((_, reject) => setTimeout(() => reject(TIMED_OUT_ERROR), 300_000)), // 5min timeout
promiseFromEvent<any, any>(token.onCancellationRequested, (_, __, reject) => { reject(USER_CANCELLATION_ERROR); }).promise
]);
codeToExchange = code;
} finally {
setTimeout(() => {
void server.stop();
}, 5000);
}
const accessToken = await exchangeCodeForToken(
logger,
baseUri.with({ path: '/login/oauth/access_token' }),
redirectUri,
codeToExchange,
enterpriseUri);
return accessToken;
});
}
},
new class DeviceCodeFlow implements IFlow {
label = l10n.t('device code');
options: IFlowOptions = {
supportsGitHubDotCom: true,
supportsGitHubEnterpriseServer: true,
supportsHostedGitHubEnterprise: true,
supportsRemoteExtensionHost: true,
// CORS prevents this from working in web workers
supportsWebWorkerExtensionHost: false,
supportsNoClientSecret: true,
supportsSupportedClients: true,
supportsUnsupportedClients: true
};
async trigger({ scopes, baseUri, logger }: IFlowTriggerOptions) {
logger.info(`Trying device code flow... (${scopes})`);
// Get initial device code
const uri = baseUri.with({
path: '/login/device/code',
query: `client_id=${Config.gitHubClientId}&scope=${scopes}`
});
const result = await fetching(uri.toString(true), {
method: 'POST',
headers: {
Accept: 'application/json'
}
});
if (!result.ok) {
throw new Error(`Failed to get one-time code: ${await result.text()}`);
}
const json = await result.json() as IGitHubDeviceCodeResponse;
const button = l10n.t('Copy & Continue to GitHub');
const modalResult = await window.showInformationMessage(
l10n.t({ message: 'Your Code: {0}', args: [json.user_code], comment: ['The {0} will be a code, e.g. 123-456'] }),
{
modal: true,
detail: l10n.t('To finish authenticating, navigate to GitHub and paste in the above one-time code.')
}, button);
if (modalResult !== button) {
throw new Error(USER_CANCELLATION_ERROR);
}
await env.clipboard.writeText(json.user_code);
const uriToOpen = await env.asExternalUri(Uri.parse(json.verification_uri));
await env.openExternal(uriToOpen);
return await this.waitForDeviceCodeAccessToken(baseUri, json);
}
private async waitForDeviceCodeAccessToken(
baseUri: Uri,
json: IGitHubDeviceCodeResponse,
): Promise<string> {
return await window.withProgress<string>({
location: ProgressLocation.Notification,
cancellable: true,
title: l10n.t({
message: 'Open [{0}]({0}) in a new tab and paste your one-time code: {1}',
args: [json.verification_uri, json.user_code],
comment: [
'The [{0}]({0}) will be a url and the {1} will be a code, e.g. 123-456',
'{Locked="[{0}]({0})"}'
]
})
}, async (_, token) => {
const refreshTokenUri = baseUri.with({
path: '/login/oauth/access_token',
query: `client_id=${Config.gitHubClientId}&device_code=${json.device_code}&grant_type=urn:ietf:params:oauth:grant-type:device_code`
});
// Try for 2 minutes
const attempts = 120 / json.interval;
for (let i = 0; i < attempts; i++) {
await new Promise(resolve => setTimeout(resolve, json.interval * 1000));
if (token.isCancellationRequested) {
throw new Error(USER_CANCELLATION_ERROR);
}
let accessTokenResult;
try {
accessTokenResult = await fetching(refreshTokenUri.toString(true), {
method: 'POST',
headers: {
Accept: 'application/json'
}
});
} catch {
continue;
}
if (!accessTokenResult.ok) {
continue;
}
const accessTokenJson = await accessTokenResult.json();
if (accessTokenJson.error === 'authorization_pending') {
continue;
}
if (accessTokenJson.error) {
throw new Error(accessTokenJson.error_description);
}
return accessTokenJson.access_token;
}
throw new Error(TIMED_OUT_ERROR);
});
}
},
new class PatFlow implements IFlow {
label = l10n.t('personal access token');
options: IFlowOptions = {
supportsGitHubDotCom: true,
supportsGitHubEnterpriseServer: true,
supportsHostedGitHubEnterprise: true,
supportsRemoteExtensionHost: true,
supportsWebWorkerExtensionHost: true,
supportsNoClientSecret: true,
// PATs can't be used with Settings Sync so we don't enable this flow
// for supported clients
supportsSupportedClients: false,
supportsUnsupportedClients: true
};
async trigger({ scopes, baseUri, logger, enterpriseUri }: IFlowTriggerOptions) {
logger.info(`Trying to retrieve PAT... (${scopes})`);
const button = l10n.t('Continue to GitHub');
const modalResult = await window.showInformationMessage(
l10n.t('Continue to GitHub to create a Personal Access Token (PAT)'),
{
modal: true,
detail: l10n.t('To finish authenticating, navigate to GitHub to create a PAT then paste the PAT into the input box.')
}, button);
if (modalResult !== button) {
throw new Error(USER_CANCELLATION_ERROR);
}
const description = `${env.appName} (${scopes})`;
const uriToOpen = await env.asExternalUri(baseUri.with({ path: '/settings/tokens/new', query: `description=${description}&scopes=${scopes.split(' ').join(',')}` }));
await env.openExternal(uriToOpen);
const token = await window.showInputBox({ placeHolder: `ghp_1a2b3c4...`, prompt: `GitHub Personal Access Token - ${scopes}`, ignoreFocusOut: true });
if (!token) { throw new Error(USER_CANCELLATION_ERROR); }
const appUri = !enterpriseUri || isHostedGitHubEnterprise(enterpriseUri)
? Uri.parse(`${baseUri.scheme}://api.${baseUri.authority}`)
: Uri.parse(`${baseUri.scheme}://${baseUri.authority}/api/v3`);
const tokenScopes = await this.getScopes(token, appUri, logger); // Example: ['repo', 'user']
const scopesList = scopes.split(' '); // Example: 'read:user repo user:email'
if (!scopesList.every(scope => {
const included = tokenScopes.includes(scope);
if (included || !scope.includes(':')) {
return included;
}
return scope.split(':').some(splitScopes => {
return tokenScopes.includes(splitScopes);
});
})) {
throw new Error(`The provided token does not match the requested scopes: ${scopes}`);
}
return token;
}
private async getScopes(token: string, serverUri: Uri, logger: Log): Promise<string[]> {
try {
logger.info('Getting token scopes...');
const result = await fetching(serverUri.toString(), {
headers: {
Authorization: `token ${token}`,
'User-Agent': `${env.appName} (${env.appHost})`
}
});
if (result.ok) {
const scopes = result.headers.get('X-OAuth-Scopes');
return scopes ? scopes.split(',').map(scope => scope.trim()) : [];
} else {
logger.error(`Getting scopes failed: ${result.statusText}`);
throw new Error(result.statusText);
}
} catch (ex) {
logger.error(ex.message);
throw new Error(NETWORK_ERROR);
}
}
}
];
export function getFlows(query: IFlowQuery) {
return allFlows.filter(flow => {
let useFlow: boolean = true;
switch (query.target) {
case GitHubTarget.DotCom:
useFlow &&= flow.options.supportsGitHubDotCom;
break;
case GitHubTarget.Enterprise:
useFlow &&= flow.options.supportsGitHubEnterpriseServer;
break;
case GitHubTarget.HostedEnterprise:
useFlow &&= flow.options.supportsHostedGitHubEnterprise;
break;
}
switch (query.extensionHost) {
case ExtensionHost.Remote:
useFlow &&= flow.options.supportsRemoteExtensionHost;
break;
case ExtensionHost.WebWorker:
useFlow &&= flow.options.supportsWebWorkerExtensionHost;
break;
}
if (!Config.gitHubClientSecret) {
useFlow &&= flow.options.supportsNoClientSecret;
}
if (query.isSupportedClient) {
// TODO: revisit how we support PAT in GHES but not DotCom... but this works for now since
// there isn't another flow that has supportsSupportedClients = false
useFlow &&= (flow.options.supportsSupportedClients || query.target !== GitHubTarget.DotCom);
} else {
useFlow &&= flow.options.supportsUnsupportedClients;
}
return useFlow;
});
}
@@ -0,0 +1,402 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import TelemetryReporter from '@vscode/extension-telemetry';
import { Keychain } from './common/keychain';
import { GitHubServer, IGitHubServer } from './githubServer';
import { PromiseAdapter, arrayEquals, promiseFromEvent } from './common/utils';
import { ExperimentationTelemetry } from './common/experimentationService';
import { Log } from './common/logger';
import { crypto } from './node/crypto';
import { TIMED_OUT_ERROR, USER_CANCELLATION_ERROR } from './common/errors';
interface SessionData {
id: string;
account?: {
label?: string;
displayName?: string;
// Unfortunately, for some time the id was a number, so we need to support both.
// This can be removed once we are confident that all users have migrated to the new id.
id: string | number;
};
scopes: string[];
accessToken: string;
}
export enum AuthProviderType {
github = 'github',
githubEnterprise = 'github-enterprise'
}
export class UriEventHandler extends vscode.EventEmitter<vscode.Uri> implements vscode.UriHandler {
private readonly _pendingNonces = new Map<string, string[]>();
private readonly _codeExchangePromises = new Map<string, { promise: Promise<string>; cancel: vscode.EventEmitter<void> }>();
public handleUri(uri: vscode.Uri) {
this.fire(uri);
}
public async waitForCode(logger: Log, scopes: string, nonce: string, token: vscode.CancellationToken) {
const existingNonces = this._pendingNonces.get(scopes) || [];
this._pendingNonces.set(scopes, [...existingNonces, nonce]);
let codeExchangePromise = this._codeExchangePromises.get(scopes);
if (!codeExchangePromise) {
codeExchangePromise = promiseFromEvent(this.event, this.handleEvent(logger, scopes));
this._codeExchangePromises.set(scopes, codeExchangePromise);
}
try {
return await Promise.race([
codeExchangePromise.promise,
new Promise<string>((_, reject) => setTimeout(() => reject(TIMED_OUT_ERROR), 300_000)), // 5min timeout
promiseFromEvent<void, string>(token.onCancellationRequested, (_, __, reject) => { reject(USER_CANCELLATION_ERROR); }).promise
]);
} finally {
this._pendingNonces.delete(scopes);
codeExchangePromise?.cancel.fire();
this._codeExchangePromises.delete(scopes);
}
}
private handleEvent: (logger: Log, scopes: string) => PromiseAdapter<vscode.Uri, string> =
(logger: Log, scopes) => (uri, resolve, reject) => {
const query = new URLSearchParams(uri.query);
const code = query.get('code');
const nonce = query.get('nonce');
if (!code) {
reject(new Error('No code'));
return;
}
if (!nonce) {
reject(new Error('No nonce'));
return;
}
const acceptedNonces = this._pendingNonces.get(scopes) || [];
if (!acceptedNonces.includes(nonce)) {
// A common scenario of this happening is if you:
// 1. Trigger a sign in with one set of scopes
// 2. Before finishing 1, you trigger a sign in with a different set of scopes
// In this scenario we should just return and wait for the next UriHandler event
// to run as we are probably still waiting on the user to hit 'Continue'
logger.info('Nonce not found in accepted nonces. Skipping this execution...');
return;
}
resolve(code);
};
}
export class GitHubAuthenticationProvider implements vscode.AuthenticationProvider, vscode.Disposable {
private readonly _sessionChangeEmitter = new vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();
private readonly _logger: Log;
private readonly _githubServer: IGitHubServer;
private readonly _telemetryReporter: ExperimentationTelemetry;
private readonly _keychain: Keychain;
private readonly _accountsSeen = new Set<string>();
private readonly _disposable: vscode.Disposable | undefined;
private _sessionsPromise: Promise<vscode.AuthenticationSession[]>;
constructor(
private readonly context: vscode.ExtensionContext,
uriHandler: UriEventHandler,
ghesUri?: vscode.Uri
) {
const { aiKey } = context.extension.packageJSON as { name: string; version: string; aiKey: string };
this._telemetryReporter = new ExperimentationTelemetry(context, new TelemetryReporter(aiKey));
const type = ghesUri ? AuthProviderType.githubEnterprise : AuthProviderType.github;
this._logger = new Log(type);
this._keychain = new Keychain(
this.context,
type === AuthProviderType.github
? `${type}.auth`
: `${ghesUri?.authority}${ghesUri?.path}.ghes.auth`,
this._logger);
this._githubServer = new GitHubServer(
this._logger,
this._telemetryReporter,
uriHandler,
context.extension.extensionKind,
ghesUri);
// Contains the current state of the sessions we have available.
this._sessionsPromise = this.readSessions().then((sessions) => {
// fire telemetry after a second to allow the workbench to focus on loading
setTimeout(() => sessions.forEach(s => this.afterSessionLoad(s)), 1000);
return sessions;
});
this._disposable = vscode.Disposable.from(
this._telemetryReporter,
vscode.authentication.registerAuthenticationProvider(type, this._githubServer.friendlyName, this, { supportsMultipleAccounts: true }),
this.context.secrets.onDidChange(() => this.checkForUpdates())
);
}
dispose() {
this._disposable?.dispose();
}
get onDidChangeSessions() {
return this._sessionChangeEmitter.event;
}
async getSessions(scopes: string[] | undefined, options?: vscode.AuthenticationProviderSessionOptions): Promise<vscode.AuthenticationSession[]> {
// For GitHub scope list, order doesn't matter so we immediately sort the scopes
const sortedScopes = scopes?.sort() || [];
this._logger.info(`Getting sessions for ${sortedScopes.length ? sortedScopes.join(',') : 'all scopes'}...`);
const sessions = await this._sessionsPromise;
const accountFilteredSessions = options?.account
? sessions.filter(session => session.account.label === options.account?.label)
: sessions;
const finalSessions = sortedScopes.length
? accountFilteredSessions.filter(session => arrayEquals([...session.scopes].sort(), sortedScopes))
: accountFilteredSessions;
this._logger.info(`Got ${finalSessions.length} sessions for ${sortedScopes?.join(',') ?? 'all scopes'}...`);
return finalSessions;
}
private async afterSessionLoad(session: vscode.AuthenticationSession): Promise<void> {
// We only want to fire a telemetry if we haven't seen this account yet in this session.
if (!this._accountsSeen.has(session.account.id)) {
this._accountsSeen.add(session.account.id);
this._githubServer.sendAdditionalTelemetryInfo(session);
}
}
private async checkForUpdates() {
const previousSessions = await this._sessionsPromise;
this._sessionsPromise = this.readSessions();
const storedSessions = await this._sessionsPromise;
const added: vscode.AuthenticationSession[] = [];
const removed: vscode.AuthenticationSession[] = [];
storedSessions.forEach(session => {
const matchesExisting = previousSessions.some(s => s.id === session.id);
// Another window added a session to the keychain, add it to our state as well
if (!matchesExisting) {
this._logger.info('Adding session found in keychain');
added.push(session);
}
});
previousSessions.forEach(session => {
const matchesExisting = storedSessions.some(s => s.id === session.id);
// Another window has logged out, remove from our state
if (!matchesExisting) {
this._logger.info('Removing session no longer found in keychain');
removed.push(session);
}
});
if (added.length || removed.length) {
this._sessionChangeEmitter.fire({ added, removed, changed: [] });
}
}
private async readSessions(): Promise<vscode.AuthenticationSession[]> {
let sessionData: SessionData[];
try {
this._logger.info('Reading sessions from keychain...');
const storedSessions = await this._keychain.getToken();
if (!storedSessions) {
return [];
}
this._logger.info('Got stored sessions!');
try {
sessionData = JSON.parse(storedSessions);
} catch (e) {
await this._keychain.deleteToken();
throw e;
}
} catch (e) {
this._logger.error(`Error reading token: ${e}`);
return [];
}
// Unfortunately, we were using a number secretly for the account id for some time... this is due to a bad `any`.
// AuthenticationSession's account id is a string, so we need to detect when there is a number accountId and re-store
// the sessions to migrate away from the bad number usage.
// TODO@TylerLeonhardt: Remove this after we are confident that all users have migrated to the new id.
let seenNumberAccountId: boolean = false;
// TODO: eventually remove this Set because we should only have one session per set of scopes.
const scopesSeen = new Set<string>();
const sessionPromises = sessionData.map(async (session: SessionData): Promise<vscode.AuthenticationSession | undefined> => {
// For GitHub scope list, order doesn't matter so we immediately sort the scopes
const scopesStr = [...session.scopes].sort().join(' ');
let userInfo: { id: string; accountName: string } | undefined;
if (!session.account) {
try {
userInfo = await this._githubServer.getUserInfo(session.accessToken);
this._logger.info(`Verified session with the following scopes: ${scopesStr}`);
} catch (e) {
// Remove sessions that return unauthorized response
if (e.message === 'Unauthorized') {
return undefined;
}
}
}
this._logger.trace(`Read the following session from the keychain with the following scopes: ${scopesStr}`);
scopesSeen.add(scopesStr);
let accountId: string;
if (session.account?.id) {
if (typeof session.account.id === 'number') {
seenNumberAccountId = true;
}
accountId = `${session.account.id}`;
} else {
accountId = userInfo?.id ?? '<unknown>';
}
return {
id: session.id,
account: {
label: session.account
? session.account.label ?? session.account.displayName ?? '<unknown>'
: userInfo?.accountName ?? '<unknown>',
id: accountId
},
// we set this to session.scopes to maintain the original order of the scopes requested
// by the extension that called getSession()
scopes: session.scopes,
accessToken: session.accessToken
};
});
const verifiedSessions = (await Promise.allSettled(sessionPromises))
.filter(p => p.status === 'fulfilled')
.map(p => (p as PromiseFulfilledResult<vscode.AuthenticationSession | undefined>).value)
.filter(<T>(p?: T): p is T => Boolean(p));
this._logger.info(`Got ${verifiedSessions.length} verified sessions.`);
if (seenNumberAccountId || verifiedSessions.length !== sessionData.length) {
await this.storeSessions(verifiedSessions);
}
return verifiedSessions;
}
private async storeSessions(sessions: vscode.AuthenticationSession[]): Promise<void> {
this._logger.info(`Storing ${sessions.length} sessions...`);
this._sessionsPromise = Promise.resolve(sessions);
await this._keychain.setToken(JSON.stringify(sessions));
this._logger.info(`Stored ${sessions.length} sessions!`);
}
public async createSession(scopes: string[], options?: vscode.AuthenticationProviderSessionOptions): Promise<vscode.AuthenticationSession> {
try {
// For GitHub scope list, order doesn't matter so we use a sorted scope to determine
// if we've got a session already.
const sortedScopes = [...scopes].sort();
/* __GDPR__
"login" : {
"owner": "TylerLeonhardt",
"comment": "Used to determine how much usage the GitHub Auth Provider gets.",
"scopes": { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight", "comment": "Used to determine what scope combinations are being requested." }
}
*/
this._telemetryReporter?.sendTelemetryEvent('login', {
scopes: JSON.stringify(scopes),
});
const sessions = await this._sessionsPromise;
const loginWith = options?.account?.label;
this._logger.info(`Logging in with '${loginWith ? loginWith : 'any'}' account...`);
const scopeString = sortedScopes.join(' ');
const token = await this._githubServer.login(scopeString, loginWith);
const session = await this.tokenToSession(token, scopes);
this.afterSessionLoad(session);
const sessionIndex = sessions.findIndex(s => s.account.id === session.account.id && arrayEquals([...s.scopes].sort(), sortedScopes));
const removed = new Array<vscode.AuthenticationSession>();
if (sessionIndex > -1) {
removed.push(...sessions.splice(sessionIndex, 1, session));
} else {
sessions.push(session);
}
await this.storeSessions(sessions);
this._sessionChangeEmitter.fire({ added: [session], removed, changed: [] });
this._logger.info('Login success!');
return session;
} catch (e) {
// If login was cancelled, do not notify user.
if (e === 'Cancelled' || e.message === 'Cancelled') {
/* __GDPR__
"loginCancelled" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users cancel the login flow." }
*/
this._telemetryReporter?.sendTelemetryEvent('loginCancelled');
throw e;
}
/* __GDPR__
"loginFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users run into an error login flow." }
*/
this._telemetryReporter?.sendTelemetryEvent('loginFailed');
vscode.window.showErrorMessage(vscode.l10n.t('Sign in failed: {0}', `${e}`));
this._logger.error(e);
throw e;
}
}
private async tokenToSession(token: string, scopes: string[]): Promise<vscode.AuthenticationSession> {
const userInfo = await this._githubServer.getUserInfo(token);
return {
id: crypto.getRandomValues(new Uint32Array(2)).reduce((prev, curr) => prev += curr.toString(16), ''),
accessToken: token,
account: { label: userInfo.accountName, id: userInfo.id },
scopes
};
}
public async removeSession(id: string) {
try {
/* __GDPR__
"logout" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users log out of an account." }
*/
this._telemetryReporter?.sendTelemetryEvent('logout');
this._logger.info(`Logging out of ${id}`);
const sessions = await this._sessionsPromise;
const sessionIndex = sessions.findIndex(session => session.id === id);
if (sessionIndex > -1) {
const session = sessions[sessionIndex];
sessions.splice(sessionIndex, 1);
await this.storeSessions(sessions);
await this._githubServer.logout(session);
this._sessionChangeEmitter.fire({ added: [], removed: [session], changed: [] });
} else {
this._logger.error('Session not found');
}
} catch (e) {
/* __GDPR__
"logoutFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often logging out of an account fails." }
*/
this._telemetryReporter?.sendTelemetryEvent('logoutFailed');
vscode.window.showErrorMessage(vscode.l10n.t('Sign out failed: {0}', `${e}`));
this._logger.error(e);
throw e;
}
}
}
@@ -0,0 +1,354 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { ExperimentationTelemetry } from './common/experimentationService';
import { AuthProviderType, UriEventHandler } from './github';
import { Log } from './common/logger';
import { isSupportedClient, isSupportedTarget } from './common/env';
import { crypto } from './node/crypto';
import { fetching } from './node/fetch';
import { ExtensionHost, GitHubTarget, getFlows } from './flows';
import { CANCELLATION_ERROR, NETWORK_ERROR, USER_CANCELLATION_ERROR } from './common/errors';
import { Config } from './config';
import { base64Encode } from './node/buffer';
const REDIRECT_URL_STABLE = 'https://vscode.dev/redirect';
const REDIRECT_URL_INSIDERS = 'https://insiders.vscode.dev/redirect';
export interface IGitHubServer {
login(scopes: string, existingLogin?: string): Promise<string>;
logout(session: vscode.AuthenticationSession): Promise<void>;
getUserInfo(token: string): Promise<{ id: string; accountName: string }>;
sendAdditionalTelemetryInfo(session: vscode.AuthenticationSession): Promise<void>;
friendlyName: string;
}
export class GitHubServer implements IGitHubServer {
readonly friendlyName: string;
private readonly _type: AuthProviderType;
private _redirectEndpoint: string | undefined;
constructor(
private readonly _logger: Log,
private readonly _telemetryReporter: ExperimentationTelemetry,
private readonly _uriHandler: UriEventHandler,
private readonly _extensionKind: vscode.ExtensionKind,
private readonly _ghesUri?: vscode.Uri
) {
this._type = _ghesUri ? AuthProviderType.githubEnterprise : AuthProviderType.github;
this.friendlyName = this._type === AuthProviderType.github ? 'GitHub' : _ghesUri?.authority!;
}
get baseUri() {
if (this._type === AuthProviderType.github) {
return vscode.Uri.parse('https://github.com/');
}
return this._ghesUri!;
}
private async getRedirectEndpoint(): Promise<string> {
if (this._redirectEndpoint) {
return this._redirectEndpoint;
}
if (this._type === AuthProviderType.github) {
const proxyEndpoints = await vscode.commands.executeCommand<{ [providerId: string]: string } | undefined>('workbench.getCodeExchangeProxyEndpoints');
// If we are running in insiders vscode.dev, then ensure we use the redirect route on that.
this._redirectEndpoint = REDIRECT_URL_STABLE;
if (proxyEndpoints?.github && new URL(proxyEndpoints.github).hostname === 'insiders.vscode.dev') {
this._redirectEndpoint = REDIRECT_URL_INSIDERS;
}
} else {
// GHE only supports a single redirect endpoint, so we can't use
// insiders.vscode.dev/redirect when we're running in Insiders, unfortunately.
// Additionally, we make the assumption that this function will only be used
// in flows that target supported GHE targets, not on-prem GHES. Because of this
// assumption, we can assume that the GHE version used is at least 3.8 which is
// the version that changed the redirect endpoint to this URI from the old
// GitHub maintained server.
this._redirectEndpoint = 'https://vscode.dev/redirect';
}
return this._redirectEndpoint;
}
// TODO@joaomoreno TODO@TylerLeonhardt
private _isNoCorsEnvironment: boolean | undefined;
private async isNoCorsEnvironment(): Promise<boolean> {
if (this._isNoCorsEnvironment !== undefined) {
return this._isNoCorsEnvironment;
}
const uri = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://vscode.github-authentication/dummy`));
this._isNoCorsEnvironment = (uri.scheme === 'https' && /^((insiders\.)?vscode|github)\./.test(uri.authority)) || (uri.scheme === 'http' && /^localhost/.test(uri.authority));
return this._isNoCorsEnvironment;
}
public async login(scopes: string, existingLogin?: string): Promise<string> {
this._logger.info(`Logging in for the following scopes: ${scopes}`);
// Used for showing a friendlier message to the user when the explicitly cancel a flow.
let userCancelled: boolean | undefined;
const yes = vscode.l10n.t('Yes');
const no = vscode.l10n.t('No');
const promptToContinue = async (mode: string) => {
if (userCancelled === undefined) {
// We haven't had a failure yet so wait to prompt
return;
}
const message = userCancelled
? vscode.l10n.t('Having trouble logging in? Would you like to try a different way? ({0})', mode)
: vscode.l10n.t('You have not yet finished authorizing this extension to use GitHub. Would you like to try a different way? ({0})', mode);
const result = await vscode.window.showWarningMessage(message, yes, no);
if (result !== yes) {
throw new Error(CANCELLATION_ERROR);
}
};
const nonce: string = crypto.getRandomValues(new Uint32Array(2)).reduce((prev, curr) => prev += curr.toString(16), '');
const callbackUri = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://vscode.github-authentication/did-authenticate?nonce=${encodeURIComponent(nonce)}`));
const supportedClient = isSupportedClient(callbackUri);
const supportedTarget = isSupportedTarget(this._type, this._ghesUri);
const flows = getFlows({
target: this._type === AuthProviderType.github
? GitHubTarget.DotCom
: supportedTarget ? GitHubTarget.HostedEnterprise : GitHubTarget.Enterprise,
extensionHost: typeof navigator === 'undefined'
? this._extensionKind === vscode.ExtensionKind.UI ? ExtensionHost.Local : ExtensionHost.Remote
: ExtensionHost.WebWorker,
isSupportedClient: supportedClient
});
for (const flow of flows) {
try {
if (flow !== flows[0]) {
await promptToContinue(flow.label);
}
return await flow.trigger({
scopes,
callbackUri,
nonce,
baseUri: this.baseUri,
logger: this._logger,
uriHandler: this._uriHandler,
enterpriseUri: this._ghesUri,
redirectUri: vscode.Uri.parse(await this.getRedirectEndpoint()),
existingLogin
});
} catch (e) {
userCancelled = this.processLoginError(e);
}
}
throw new Error(userCancelled ? CANCELLATION_ERROR : 'No auth flow succeeded.');
}
public async logout(session: vscode.AuthenticationSession): Promise<void> {
this._logger.trace(`Deleting session (${session.id}) from server...`);
if (!Config.gitHubClientSecret) {
this._logger.warn('No client secret configured for GitHub authentication. The token has been deleted with best effort on this system, but we are unable to delete the token on server without the client secret.');
return;
}
// Only attempt to delete OAuth tokens. They are always prefixed with `gho_`.
// https://docs.github.com/en/rest/apps/oauth-applications#about-oauth-apps-and-oauth-authorizations-of-github-apps
if (!session.accessToken.startsWith('gho_')) {
this._logger.warn('The token being deleted is not an OAuth token. It has been deleted locally, but we cannot delete it on server.');
return;
}
if (!isSupportedTarget(this._type, this._ghesUri)) {
this._logger.trace('GitHub.com and GitHub hosted GitHub Enterprise are the only options that support deleting tokens on the server. Skipping.');
return;
}
const authHeader = 'Basic ' + base64Encode(`${Config.gitHubClientId}:${Config.gitHubClientSecret}`);
const uri = this.getServerUri(`/applications/${Config.gitHubClientId}/token`);
try {
// Defined here: https://docs.github.com/en/rest/apps/oauth-applications?apiVersion=2022-11-28#delete-an-app-token
const result = await fetching(uri.toString(true), {
method: 'DELETE',
headers: {
Accept: 'application/vnd.github+json',
Authorization: authHeader,
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': `${vscode.env.appName} (${vscode.env.appHost})`
},
body: JSON.stringify({ access_token: session.accessToken }),
});
if (result.status === 204) {
this._logger.trace(`Successfully deleted token from session (${session.id}) from server.`);
return;
}
try {
const body = await result.text();
throw new Error(body);
} catch (e) {
throw new Error(`${result.status} ${result.statusText}`);
}
} catch (e) {
this._logger.warn('Failed to delete token from server.' + (e.message ?? e));
}
}
private getServerUri(path: string = '') {
const apiUri = this.baseUri;
// github.com and Hosted GitHub Enterprise instances
if (isSupportedTarget(this._type, this._ghesUri)) {
return vscode.Uri.parse(`${apiUri.scheme}://api.${apiUri.authority}`).with({ path });
}
// GitHub Enterprise Server (aka on-prem)
return vscode.Uri.parse(`${apiUri.scheme}://${apiUri.authority}/api/v3${path}`);
}
public async getUserInfo(token: string): Promise<{ id: string; accountName: string }> {
let result;
try {
this._logger.info('Getting user info...');
result = await fetching(this.getServerUri('/user').toString(), {
headers: {
Authorization: `token ${token}`,
'User-Agent': `${vscode.env.appName} (${vscode.env.appHost})`
}
});
} catch (ex) {
this._logger.error(ex.message);
throw new Error(NETWORK_ERROR);
}
if (result.ok) {
try {
const json = await result.json() as { id: number; login: string };
this._logger.info('Got account info!');
return { id: `${json.id}`, accountName: json.login };
} catch (e) {
this._logger.error(`Unexpected error parsing response from GitHub: ${e.message ?? e}`);
throw e;
}
} else {
// either display the response message or the http status text
let errorMessage = result.statusText;
try {
const json = await result.json();
if (json.message) {
errorMessage = json.message;
}
} catch (err) {
// noop
}
this._logger.error(`Getting account info failed: ${errorMessage}`);
throw new Error(errorMessage);
}
}
public async sendAdditionalTelemetryInfo(session: vscode.AuthenticationSession): Promise<void> {
if (!vscode.env.isTelemetryEnabled) {
return;
}
const nocors = await this.isNoCorsEnvironment();
if (nocors) {
return;
}
if (this._type === AuthProviderType.github) {
return await this.checkUserDetails(session);
}
// GHES
await this.checkEnterpriseVersion(session.accessToken);
}
private async checkUserDetails(session: vscode.AuthenticationSession): Promise<void> {
let edu: string | undefined;
try {
const result = await fetching('https://education.github.com/api/user', {
headers: {
Authorization: `token ${session.accessToken}`,
'faculty-check-preview': 'true',
'User-Agent': `${vscode.env.appName} (${vscode.env.appHost})`
}
});
if (result.ok) {
const json: { student: boolean; faculty: boolean } = await result.json();
edu = json.student
? 'student'
: json.faculty
? 'faculty'
: 'none';
} else {
edu = 'unknown';
}
} catch (e) {
edu = 'unknown';
}
/* __GDPR__
"session" : {
"owner": "TylerLeonhardt",
"isEdu": { "classification": "SystemMetaData", "purpose": "FeatureInsight" },
"isManaged": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }
}
*/
this._telemetryReporter.sendTelemetryEvent('session', {
isEdu: edu,
// Apparently, this is how you tell if a user is an EMU...
isManaged: session.account.label.includes('_') ? 'true' : 'false'
});
}
private async checkEnterpriseVersion(token: string): Promise<void> {
try {
let version: string;
if (!isSupportedTarget(this._type, this._ghesUri)) {
const result = await fetching(this.getServerUri('/meta').toString(), {
headers: {
Authorization: `token ${token}`,
'User-Agent': `${vscode.env.appName} (${vscode.env.appHost})`
}
});
if (!result.ok) {
return;
}
const json: { verifiable_password_authentication: boolean; installed_version: string } = await result.json();
version = json.installed_version;
} else {
version = 'hosted';
}
/* __GDPR__
"ghe-session" : {
"owner": "TylerLeonhardt",
"version": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }
}
*/
this._telemetryReporter.sendTelemetryEvent('ghe-session', {
version
});
} catch {
// No-op
}
}
private processLoginError(error: Error): boolean {
if (error.message === CANCELLATION_ERROR) {
throw error;
}
this._logger.error(error.message ?? error);
return error.message === USER_CANCELLATION_ERROR;
}
}
@@ -0,0 +1,198 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as http from 'http';
import { URL } from 'url';
import * as fs from 'fs';
import * as path from 'path';
import { randomBytes } from 'crypto';
function sendFile(res: http.ServerResponse, filepath: string) {
fs.readFile(filepath, (err, body) => {
if (err) {
console.error(err);
res.writeHead(404);
res.end();
} else {
res.writeHead(200, {
'content-length': body.length,
});
res.end(body);
}
});
}
interface IOAuthResult {
code: string;
state: string;
}
interface ILoopbackServer {
/**
* If undefined, the server is not started yet.
*/
port: number | undefined;
/**
* The nonce used
*/
nonce: string;
/**
* The state parameter used in the OAuth flow.
*/
state: string | undefined;
/**
* Starts the server.
* @returns The port to listen on.
* @throws If the server fails to start.
* @throws If the server is already started.
*/
start(): Promise<number>;
/**
* Stops the server.
* @throws If the server is not started.
* @throws If the server fails to stop.
*/
stop(): Promise<void>;
/**
* Returns a promise that resolves to the result of the OAuth flow.
*/
waitForOAuthResponse(): Promise<IOAuthResult>;
}
export class LoopbackAuthServer implements ILoopbackServer {
private readonly _server: http.Server;
private readonly _resultPromise: Promise<IOAuthResult>;
private _startingRedirect: URL;
public nonce = randomBytes(16).toString('base64');
public port: number | undefined;
public set state(state: string | undefined) {
if (state) {
this._startingRedirect.searchParams.set('state', state);
} else {
this._startingRedirect.searchParams.delete('state');
}
}
public get state(): string | undefined {
return this._startingRedirect.searchParams.get('state') ?? undefined;
}
constructor(serveRoot: string, startingRedirect: string) {
if (!serveRoot) {
throw new Error('serveRoot must be defined');
}
if (!startingRedirect) {
throw new Error('startingRedirect must be defined');
}
this._startingRedirect = new URL(startingRedirect);
let deferred: { resolve: (result: IOAuthResult) => void; reject: (reason: any) => void };
this._resultPromise = new Promise<IOAuthResult>((resolve, reject) => deferred = { resolve, reject });
this._server = http.createServer((req, res) => {
const reqUrl = new URL(req.url!, `http://${req.headers.host}`);
switch (reqUrl.pathname) {
case '/signin': {
const receivedNonce = (reqUrl.searchParams.get('nonce') ?? '').replace(/ /g, '+');
if (receivedNonce !== this.nonce) {
res.writeHead(302, { location: `/?error=${encodeURIComponent('Nonce does not match.')}` });
res.end();
}
res.writeHead(302, { location: this._startingRedirect.toString() });
res.end();
break;
}
case '/callback': {
const code = reqUrl.searchParams.get('code') ?? undefined;
const state = reqUrl.searchParams.get('state') ?? undefined;
const nonce = (reqUrl.searchParams.get('nonce') ?? '').replace(/ /g, '+');
if (!code || !state || !nonce) {
res.writeHead(400);
res.end();
return;
}
if (this.state !== state) {
res.writeHead(302, { location: `/?error=${encodeURIComponent('State does not match.')}` });
res.end();
throw new Error('State does not match.');
}
if (this.nonce !== nonce) {
res.writeHead(302, { location: `/?error=${encodeURIComponent('Nonce does not match.')}` });
res.end();
throw new Error('Nonce does not match.');
}
deferred.resolve({ code, state });
res.writeHead(302, { location: '/' });
res.end();
break;
}
// Serve the static files
case '/':
sendFile(res, path.join(serveRoot, 'index.html'));
break;
default:
// substring to get rid of leading '/'
sendFile(res, path.join(serveRoot, reqUrl.pathname.substring(1)));
break;
}
});
}
public start(): Promise<number> {
return new Promise<number>((resolve, reject) => {
if (this._server.listening) {
throw new Error('Server is already started');
}
const portTimeout = setTimeout(() => {
reject(new Error('Timeout waiting for port'));
}, 5000);
this._server.on('listening', () => {
const address = this._server.address();
if (typeof address === 'string') {
this.port = parseInt(address);
} else if (address instanceof Object) {
this.port = address.port;
} else {
throw new Error('Unable to determine port');
}
clearTimeout(portTimeout);
// set state which will be used to redirect back to vscode
this.state = `http://127.0.0.1:${this.port}/callback?nonce=${encodeURIComponent(this.nonce)}`;
resolve(this.port);
});
this._server.on('error', err => {
reject(new Error(`Error listening to server: ${err}`));
});
this._server.on('close', () => {
reject(new Error('Closed'));
});
this._server.listen(0, '127.0.0.1');
});
}
public stop(): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (!this._server.listening) {
throw new Error('Server is not started');
}
this._server.close((err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
public waitForOAuthResponse(): Promise<IOAuthResult> {
return this._resultPromise;
}
}
@@ -0,0 +1,8 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
export function base64Encode(text: string): string {
return Buffer.from(text, 'binary').toString('base64');
}
@@ -0,0 +1,8 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { webcrypto } from 'crypto';
export const crypto = webcrypto as any as Crypto;
@@ -0,0 +1,12 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
let _fetch: typeof fetch;
try {
_fetch = require('electron').net.fetch;
} catch {
_fetch = fetch;
}
export const fetching = _fetch;
@@ -0,0 +1,196 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import { ExtensionHost, GitHubTarget, IFlowQuery, getFlows } from '../flows';
import { Config } from '../config';
const enum Flows {
UrlHandlerFlow = 'url handler',
LocalServerFlow = 'local server',
DeviceCodeFlow = 'device code',
PatFlow = 'personal access token'
}
suite('getFlows', () => {
let lastClientSecret: string | undefined = undefined;
suiteSetup(() => {
lastClientSecret = Config.gitHubClientSecret;
Config.gitHubClientSecret = 'asdf';
});
suiteTeardown(() => {
Config.gitHubClientSecret = lastClientSecret;
});
const testCases: Array<{ label: string; query: IFlowQuery; expectedFlows: Flows[] }> = [
{
label: 'VS Code Desktop. Local filesystem. GitHub.com',
query: {
extensionHost: ExtensionHost.Local,
isSupportedClient: true,
target: GitHubTarget.DotCom
},
expectedFlows: [
Flows.UrlHandlerFlow,
Flows.LocalServerFlow,
Flows.DeviceCodeFlow
]
},
{
label: 'VS Code Desktop. Local filesystem. GitHub Hosted Enterprise',
query: {
extensionHost: ExtensionHost.Local,
isSupportedClient: true,
target: GitHubTarget.HostedEnterprise
},
expectedFlows: [
Flows.UrlHandlerFlow,
Flows.LocalServerFlow,
Flows.DeviceCodeFlow,
Flows.PatFlow
]
},
{
label: 'VS Code Desktop. Local filesystem. GitHub Enterprise Server',
query: {
extensionHost: ExtensionHost.Local,
isSupportedClient: true,
target: GitHubTarget.Enterprise
},
expectedFlows: [
Flows.DeviceCodeFlow,
Flows.PatFlow
]
},
{
label: 'vscode.dev. serverful. GitHub.com',
query: {
extensionHost: ExtensionHost.Remote,
isSupportedClient: true,
target: GitHubTarget.DotCom
},
expectedFlows: [
Flows.UrlHandlerFlow,
Flows.DeviceCodeFlow
]
},
{
label: 'vscode.dev. serverful. GitHub Hosted Enterprise',
query: {
extensionHost: ExtensionHost.Remote,
isSupportedClient: true,
target: GitHubTarget.HostedEnterprise
},
expectedFlows: [
Flows.UrlHandlerFlow,
Flows.DeviceCodeFlow,
Flows.PatFlow
]
},
{
label: 'vscode.dev. serverful. GitHub Enterprise',
query: {
extensionHost: ExtensionHost.Remote,
isSupportedClient: true,
target: GitHubTarget.Enterprise
},
expectedFlows: [
Flows.DeviceCodeFlow,
Flows.PatFlow
]
},
{
label: 'vscode.dev. serverless. GitHub.com',
query: {
extensionHost: ExtensionHost.WebWorker,
isSupportedClient: true,
target: GitHubTarget.DotCom
},
expectedFlows: [
Flows.UrlHandlerFlow
]
},
{
label: 'vscode.dev. serverless. GitHub Hosted Enterprise',
query: {
extensionHost: ExtensionHost.WebWorker,
isSupportedClient: true,
target: GitHubTarget.HostedEnterprise
},
expectedFlows: [
Flows.UrlHandlerFlow,
Flows.PatFlow
]
},
{
label: 'vscode.dev. serverless. GitHub Enterprise Server',
query: {
extensionHost: ExtensionHost.WebWorker,
isSupportedClient: true,
target: GitHubTarget.Enterprise
},
expectedFlows: [
Flows.PatFlow
]
},
{
label: 'Code - OSS. Local filesystem. GitHub.com',
query: {
extensionHost: ExtensionHost.Local,
isSupportedClient: false,
target: GitHubTarget.DotCom
},
expectedFlows: [
Flows.LocalServerFlow,
Flows.DeviceCodeFlow,
Flows.PatFlow
]
},
{
label: 'Code - OSS. Local filesystem. GitHub Hosted Enterprise',
query: {
extensionHost: ExtensionHost.Local,
isSupportedClient: false,
target: GitHubTarget.HostedEnterprise
},
expectedFlows: [
Flows.LocalServerFlow,
Flows.DeviceCodeFlow,
Flows.PatFlow
]
},
{
label: 'Code - OSS. Local filesystem. GitHub Enterprise Server',
query: {
extensionHost: ExtensionHost.Local,
isSupportedClient: false,
target: GitHubTarget.Enterprise
},
expectedFlows: [
Flows.DeviceCodeFlow,
Flows.PatFlow
]
},
];
for (const testCase of testCases) {
test(`gives the correct flows - ${testCase.label}`, () => {
const flows = getFlows(testCase.query);
assert.strictEqual(
flows.length,
testCase.expectedFlows.length,
`Unexpected number of flows: ${flows.map(f => f.label).join(',')}`
);
for (let i = 0; i < flows.length; i++) {
const flow = flows[i];
assert.strictEqual(flow.label, testCase.expectedFlows[i]);
}
});
}
});
@@ -0,0 +1,65 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import { LoopbackAuthServer } from '../../node/authServer';
suite('LoopbackAuthServer', () => {
let server: LoopbackAuthServer;
let port: number;
setup(async () => {
server = new LoopbackAuthServer(__dirname, 'http://localhost:8080');
port = await server.start();
});
teardown(async () => {
await server.stop();
});
test('should redirect to starting redirect on /signin', async () => {
const response = await fetch(`http://localhost:${port}/signin?nonce=${server.nonce}`, {
redirect: 'manual'
});
// Redirect
assert.strictEqual(response.status, 302);
// Check location
const location = response.headers.get('location');
assert.ok(location);
const locationUrl = new URL(location);
assert.strictEqual(locationUrl.origin, 'http://localhost:8080');
// Check state
const state = locationUrl.searchParams.get('state');
assert.ok(state);
const stateLocation = new URL(state);
assert.strictEqual(stateLocation.origin, `http://127.0.0.1:${port}`);
assert.strictEqual(stateLocation.pathname, '/callback');
assert.strictEqual(stateLocation.searchParams.get('nonce'), server.nonce);
});
test('should return 400 on /callback with missing parameters', async () => {
const response = await fetch(`http://localhost:${port}/callback`);
assert.strictEqual(response.status, 400);
});
test('should resolve with code and state on /callback with valid parameters', async () => {
server.state = 'valid-state';
const response = await fetch(
`http://localhost:${port}/callback?code=valid-code&state=${server.state}&nonce=${server.nonce}`,
{ redirect: 'manual' }
);
assert.strictEqual(response.status, 302);
assert.strictEqual(response.headers.get('location'), '/');
await Promise.race([
server.waitForOAuthResponse().then(result => {
assert.strictEqual(result.code, 'valid-code');
assert.strictEqual(result.state, server.state);
}),
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 5000))
]);
});
});
@@ -0,0 +1,17 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./out",
"experimentalDecorators": true,
"typeRoots": [
"./node_modules/@types"
],
"lib": [
"WebWorker"
]
},
"include": [
"src/**/*",
"../../src/vscode-dts/vscode.d.ts"
]
}