Polish Sovereign Cloud support (#184634)

* Use `@azure/ms-rest-azure-env` as official reference of endpoints
* Allow better configuration of custom clouds (these are new so it is ok to change the settings without migration)

Also clean up:

* querystring -> URLSearchParams (getting rid of a package dependency in the web)
* handle `workbench.getCodeExchangeProxyEndpoints` in one place
This commit is contained in:
Tyler James Leonhardt 2023-06-08 12:39:29 -07:00 committed by GitHub
parent 1ea4242445
commit dd2441f7ba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 145 additions and 83 deletions

View file

@ -24,9 +24,6 @@ module.exports = withBrowserDefaults({
'keytar': 'commonjs keytar',
},
resolve: {
fallback: {
'querystring': require.resolve('querystring-es3')
},
alias: {
'./node/crypto': path.resolve(__dirname, 'src/browser/crypto'),
'./node/authServer': path.resolve(__dirname, 'src/browser/authServer'),

View file

@ -41,20 +41,58 @@
{
"title": "Microsoft Sovereign Cloud",
"properties": {
"microsoft-sovereign-cloud.endpoint": {
"anyOf": [
{
"type": "string"
},
{
"type": "string",
"enum": [
"Azure China",
"Azure US Government"
]
}
"microsoft-sovereign-cloud.environment": {
"type": "string",
"markdownDescription": "%microsoft-sovereign-cloud.environment.description%",
"enum": [
"ChinaCloud",
"USGovernment",
"custom"
],
"description": "%microsoft-sovereign-cloud.endpoint.description%"
"enumDescriptions": [
"%microsoft-sovereign-cloud.environment.enumDescriptions.AzureChinaCloud%",
"%microsoft-sovereign-cloud.environment.enumDescriptions.AzureUSGovernment%",
"%microsoft-sovereign-cloud.environment.enumDescriptions.custom%"
]
},
"microsoft-sovereign-cloud.customEnvironment": {
"type": "object",
"additionalProperties": true,
"markdownDescription": "%microsoft-sovereign-cloud.customEnvironment.description%",
"properties": {
"name": {
"type": "string",
"description": "%microsoft-sovereign-cloud.customEnvironment.name.description%"
},
"portalUrl": {
"type": "string",
"description": "%microsoft-sovereign-cloud.customEnvironment.portalUrl.description%"
},
"managementEndpointUrl": {
"type": "string",
"description": "%microsoft-sovereign-cloud.customEnvironment.managementEndpointUrl.description%"
},
"resourceManagerEndpointUrl": {
"type": "string",
"description": "%microsoft-sovereign-cloud.customEnvironment.resourceManagerEndpointUrl.description%"
},
"activeDirectoryEndpointUrl": {
"type": "string",
"description": "%microsoft-sovereign-cloud.customEnvironment.activeDirectoryEndpointUrl.description%"
},
"activeDirectoryResourceId": {
"type": "string",
"description": "%microsoft-sovereign-cloud.customEnvironment.activeDirectoryResourceId.description%"
}
},
"required": [
"name",
"portalUrl",
"managementEndpointUrl",
"resourceManagerEndpointUrl",
"activeDirectoryEndpointUrl",
"activeDirectoryResourceId"
]
}
}
}
@ -75,11 +113,11 @@
"@types/node-fetch": "^2.5.7",
"@types/randombytes": "^2.0.0",
"@types/sha.js": "^2.4.0",
"@types/uuid": "8.0.0",
"querystring-es3": "^0.2.1"
"@types/uuid": "8.0.0"
},
"dependencies": {
"node-fetch": "2.6.7",
"@azure/ms-rest-azure-env": "^2.0.0",
"@vscode/extension-telemetry": "0.7.5"
},
"repository": {

View file

@ -3,5 +3,27 @@
"description": "Microsoft authentication provider",
"signIn": "Sign In",
"signOut": "Sign Out",
"microsoft-sovereign-cloud.endpoint.description": "Login endpoint for Azure authentication. Select a national cloud or enter the login URL for a custom Azure cloud."
"microsoft-sovereign-cloud.environment.description": {
"message": "The Sovereign Cloud to use for authentication. If you select `custom`, you must also set the `#microsoft-sovereign-cloud.customEnvironment#` setting.",
"comment": [
"{Locked='`#microsoft-sovereign-cloud.customEnvironment#`'}",
"The `#microsoft-sovereign-cloud.customEnvironment#` syntax will turn into a link. Do not translate it."
]
},
"microsoft-sovereign-cloud.environment.enumDescriptions.AzureChinaCloud": "Azure China",
"microsoft-sovereign-cloud.environment.enumDescriptions.AzureUSGovernment": "Azure US Government",
"microsoft-sovereign-cloud.environment.enumDescriptions.custom": "A custom Microsoft Sovereign Cloud",
"microsoft-sovereign-cloud.customEnvironment.description": {
"message": "The custom configuration for the Sovereign Cloud to use with the Microsoft Sovereign Cloud authentication provider. This along with setting `#microsoft-sovereign-cloud.environment#` to `custom` is required to use this feature.",
"comment": [
"{Locked='`#microsoft-sovereign-cloud.environment#`'}",
"The `#microsoft-sovereign-cloud.environment#` syntax will turn into a link. Do not translate it."
]
},
"microsoft-sovereign-cloud.customEnvironment.name.description": "The name of the custom Sovereign Cloud.",
"microsoft-sovereign-cloud.customEnvironment.portalUrl.description": "The portal URL for the custom Sovereign Cloud.",
"microsoft-sovereign-cloud.customEnvironment.managementEndpointUrl.description": "The management endpoint for the custom Sovereign Cloud.",
"microsoft-sovereign-cloud.customEnvironment.resourceManagerEndpointUrl.description": "The resource manager endpoint for the custom Sovereign Cloud.",
"microsoft-sovereign-cloud.customEnvironment.activeDirectoryEndpointUrl.description": "The Active Directory endpoint for the custom Sovereign Cloud.",
"microsoft-sovereign-cloud.customEnvironment.activeDirectoryResourceId.description": "The Active Directory resource ID for the custom Sovereign Cloud."
}

View file

@ -4,7 +4,6 @@
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import * as querystring from 'querystring';
import * as path from 'path';
import { isSupportedEnvironment } from './utils';
import { generateCodeChallenge, generateCodeVerifier, randomUUID } from './cryptoUtils';
@ -14,9 +13,10 @@ import { base64Decode } from './node/buffer';
import { fetching } from './node/fetch';
import { UriEventHandler } from './UriEventHandler';
import TelemetryReporter from '@vscode/extension-telemetry';
import { Environment } from '@azure/ms-rest-azure-env';
const redirectUrl = 'https://vscode.dev/redirect';
const defaultLoginEndpointUrl = 'https://login.microsoftonline.com/';
const defaultActiveDirectoryEndpointUrl = Environment.AzureCloud.activeDirectoryEndpointUrl;
const DEFAULT_CLIENT_ID = 'aebc6443-996d-45c2-90f0-388ff96faa56';
const DEFAULT_TENANT = 'organizations';
const MSA_TID = '9188040d-6c67-4c5b-b112-36a304b66dad';
@ -102,7 +102,7 @@ export class AzureActiveDirectoryService {
private readonly _uriHandler: UriEventHandler,
private readonly _tokenStorage: BetterTokenStorage<IStoredSession>,
private readonly _telemetryReporter: TelemetryReporter,
private readonly _loginEndpointUrl: string = defaultLoginEndpointUrl
private readonly _env: Environment
) {
_context.subscriptions.push(this._tokenStorage.onDidChangeInOtherWindow((e) => this.checkForUpdates(e)));
}
@ -301,7 +301,7 @@ export class AzureActiveDirectoryService {
const runsRemote = vscode.env.remoteName !== undefined;
const runsServerless = vscode.env.remoteName === undefined && vscode.env.uiKind === vscode.UIKind.Web;
if (runsServerless && this._loginEndpointUrl !== defaultLoginEndpointUrl) {
if (runsServerless && this._env.activeDirectoryEndpointUrl !== defaultActiveDirectoryEndpointUrl) {
throw new Error('Sign in to non-public clouds is not supported on the web.');
}
@ -338,7 +338,7 @@ export class AzureActiveDirectoryService {
code_challenge_method: 'S256',
code_challenge: codeChallenge,
}).toString();
const loginUrl = `${this._loginEndpointUrl}${scopeData.tenant}/oauth2/v2.0/authorize?${qs}`;
const loginUrl = new URL(`${scopeData.tenant}/oauth2/v2.0/authorize?${qs}`, this._env.activeDirectoryEndpointUrl).toString();
const server = new LoopbackAuthServer(path.join(__dirname, '../media'), loginUrl);
await server.start();
@ -368,8 +368,8 @@ export class AzureActiveDirectoryService {
const state = encodeURIComponent(callbackUri.toString(true));
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const signInUrl = `${this._loginEndpointUrl}${scopeData.tenant}/oauth2/v2.0/authorize`;
const oauthStartQuery = new URLSearchParams({
const signInUrl = new URL(`${scopeData.tenant}/oauth2/v2.0/authorize`, this._env.activeDirectoryEndpointUrl);
signInUrl.search = new URLSearchParams({
response_type: 'code',
client_id: encodeURIComponent(scopeData.clientId),
response_mode: 'query',
@ -379,8 +379,8 @@ export class AzureActiveDirectoryService {
prompt: 'select_account',
code_challenge_method: 'S256',
code_challenge: codeChallenge,
});
const uri = vscode.Uri.parse(`${signInUrl}?${oauthStartQuery.toString()}`);
}).toString();
const uri = vscode.Uri.parse(signInUrl.toString());
vscode.env.openExternal(uri);
let inputBox: vscode.InputBox | undefined;
@ -601,19 +601,15 @@ export class AzureActiveDirectoryService {
private async doRefreshToken(refreshToken: string, scopeData: IScopeData, sessionId?: string): Promise<IToken> {
this._logger.info(`Refreshing token for scopes: ${scopeData.scopeStr}`);
const postData = querystring.stringify({
const postData = new URLSearchParams({
refresh_token: refreshToken,
client_id: scopeData.clientId,
grant_type: 'refresh_token',
scope: scopeData.scopesToSend
});
const proxyEndpoints: { [providerId: string]: string } | undefined = await vscode.commands.executeCommand('workbench.getCodeExchangeProxyEndpoints');
const endpointUrl = proxyEndpoints?.microsoft || this._loginEndpointUrl;
const endpoint = `${endpointUrl}${scopeData.tenant}/oauth2/v2.0/token`;
}).toString();
try {
const json = await this.fetchTokenResponse(endpoint, postData, scopeData);
const json = await this.fetchTokenResponse(postData, scopeData);
const token = this.convertToTokenSync(json, scopeData, sessionId);
if (token.expiresIn) {
this.setSessionTimeout(token.sessionId, token.refreshToken, scopeData, token.expiresIn * AzureActiveDirectoryService.REFRESH_TIMEOUT_MODIFIER);
@ -666,8 +662,9 @@ export class AzureActiveDirectoryService {
return new Promise((resolve: (value: vscode.AuthenticationSession) => void, reject) => {
uriEventListener = this._uriHandler.event(async (uri: vscode.Uri) => {
try {
const query = querystring.parse(uri.query);
let { code, nonce } = query;
const query = new URLSearchParams(uri.query);
let code = query.get('code');
let nonce = query.get('nonce');
if (Array.isArray(code)) {
code = code[0];
}
@ -735,28 +732,16 @@ export class AzureActiveDirectoryService {
this._logger.info(`Exchanging login code for token for scopes: ${scopeData.scopeStr}`);
let token: IToken | undefined;
try {
const postData = querystring.stringify({
const postData = new URLSearchParams({
grant_type: 'authorization_code',
code: code,
client_id: scopeData.clientId,
scope: scopeData.scopesToSend,
code_verifier: codeVerifier,
redirect_uri: redirectUrl
});
}).toString();
let endpointUrl: string;
if (this._loginEndpointUrl !== defaultLoginEndpointUrl) {
// If this is for sovereign clouds, don't try using the proxy endpoint, which supports only public cloud
endpointUrl = this._loginEndpointUrl;
} else {
const proxyEndpoints: { [providerId: string]: string } | undefined = await vscode.commands.executeCommand('workbench.getCodeExchangeProxyEndpoints');
endpointUrl = proxyEndpoints?.microsoft || this._loginEndpointUrl;
}
const endpoint = `${endpointUrl}${scopeData.tenant}/oauth2/v2.0/token`;
const json = await this.fetchTokenResponse(endpoint, postData, scopeData);
const json = await this.fetchTokenResponse(postData, scopeData);
this._logger.info(`Exchanging login code for token (for scopes: ${scopeData.scopeStr}) succeeded!`);
token = this.convertToTokenSync(json, scopeData);
} catch (e) {
@ -772,7 +757,17 @@ export class AzureActiveDirectoryService {
return await this.convertToSession(token, scopeData);
}
private async fetchTokenResponse(endpoint: string, postData: string, scopeData: IScopeData): Promise<ITokenResponse> {
private async fetchTokenResponse(postData: string, scopeData: IScopeData): Promise<ITokenResponse> {
let endpointUrl: string;
if (this._env.activeDirectoryEndpointUrl !== defaultActiveDirectoryEndpointUrl) {
// If this is for sovereign clouds, don't try using the proxy endpoint, which supports only public cloud
endpointUrl = this._env.activeDirectoryEndpointUrl;
} else {
const proxyEndpoints: { [providerId: string]: string } | undefined = await vscode.commands.executeCommand('workbench.getCodeExchangeProxyEndpoints');
endpointUrl = proxyEndpoints?.microsoft || this._env.activeDirectoryEndpointUrl;
}
const endpoint = new URL(`${scopeData.tenant}/oauth2/v2.0/token`, endpointUrl);
let attempts = 0;
while (attempts <= 3) {
attempts++;
@ -869,7 +864,7 @@ export class AzureActiveDirectoryService {
refreshToken: token.refreshToken,
scope: token.scope,
account: token.account,
endpoint: this._loginEndpointUrl,
endpoint: this._env.activeDirectoryEndpointUrl,
});
this._logger.info(`Stored token for scopes: ${scopeData.scopeStr}`);
}
@ -933,9 +928,9 @@ export class AzureActiveDirectoryService {
private sessionMatchesEndpoint(session: IStoredSession): boolean {
// For older sessions with no endpoint set, it can be assumed to be the default endpoint
session.endpoint ||= defaultLoginEndpointUrl;
session.endpoint ||= defaultActiveDirectoryEndpointUrl;
return session.endpoint === this._loginEndpointUrl;
return session.endpoint === this._env.activeDirectoryEndpointUrl;
}
//#endregion

View file

@ -4,36 +4,46 @@
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { Environment, EnvironmentParameters } from '@azure/ms-rest-azure-env';
import { AzureActiveDirectoryService, IStoredSession } from './AADHelper';
import { BetterTokenStorage } from './betterSecretStorage';
import { UriEventHandler } from './UriEventHandler';
import TelemetryReporter from '@vscode/extension-telemetry';
async function initMicrosoftSovereignCloudAuthProvider(context: vscode.ExtensionContext, telemetryReporter: TelemetryReporter, uriHandler: UriEventHandler, tokenStorage: BetterTokenStorage<IStoredSession>): Promise<vscode.Disposable | undefined> {
let settingValue = vscode.workspace.getConfiguration('microsoft-sovereign-cloud').get<string | undefined>('endpoint');
const environment = vscode.workspace.getConfiguration('microsoft-sovereign-cloud').get<string | undefined>('environment');
let authProviderName: string | undefined;
if (!settingValue) {
if (!environment) {
return undefined;
} else if (settingValue === 'Azure China') {
authProviderName = settingValue;
settingValue = 'https://login.chinacloudapi.cn/';
} else if (settingValue === 'Azure US Government') {
authProviderName = settingValue;
settingValue = 'https://login.microsoftonline.us/';
}
// validate user value
let uri: vscode.Uri;
try {
uri = vscode.Uri.parse(settingValue, true);
} catch (e) {
vscode.window.showErrorMessage(vscode.l10n.t('Microsoft Sovereign Cloud login URI is not a valid URI: {0}', e.message ?? e));
return;
if (environment === 'custom') {
const customEnv = vscode.workspace.getConfiguration('microsoft-sovereign-cloud').get<EnvironmentParameters>('customEnvironment');
if (!customEnv) {
const res = await vscode.window.showErrorMessage(vscode.l10n.t('You must also specify a custom environment in order to use the custom environment auth provider.'), vscode.l10n.t('Open settings'));
if (res) {
await vscode.commands.executeCommand('workbench.action.openSettingsJson', 'microsoft-sovereign-cloud.customEnvironment');
}
return undefined;
}
try {
Environment.add(customEnv);
} catch (e) {
const res = await vscode.window.showErrorMessage(vscode.l10n.t('Error validating custom environment setting: {0}', e.message), vscode.l10n.t('Open settings'));
if (res) {
await vscode.commands.executeCommand('workbench.action.openSettings', 'microsoft-sovereign-cloud.customEnvironment');
}
return undefined;
}
authProviderName = customEnv.name;
} else {
authProviderName = environment;
}
// Add trailing slash if needed
if (!settingValue.endsWith('/')) {
settingValue += '/';
const env = Environment.get(authProviderName);
if (!env) {
const res = await vscode.window.showErrorMessage(vscode.l10n.t('The environment `{0}` is not a valid environment.', authProviderName), vscode.l10n.t('Open settings'));
return undefined;
}
const aadService = new AzureActiveDirectoryService(
@ -42,10 +52,9 @@ async function initMicrosoftSovereignCloudAuthProvider(context: vscode.Extension
uriHandler,
tokenStorage,
telemetryReporter,
settingValue);
env);
await aadService.initialize();
authProviderName ||= uri.authority;
const disposable = vscode.authentication.registerAuthenticationProvider('microsoft-sovereign-cloud', authProviderName, {
onDidChangeSessions: aadService.onDidChangeSessions,
getSessions: (scopes: string[]) => aadService.getSessions(scopes),
@ -108,7 +117,8 @@ export async function activate(context: vscode.ExtensionContext) {
context,
uriHandler,
betterSecretStorage,
telemetryReporter);
telemetryReporter,
Environment.AzureCloud);
await loginService.initialize();
context.subscriptions.push(vscode.authentication.registerAuthenticationProvider('microsoft', 'Microsoft', {
@ -158,7 +168,7 @@ export async function activate(context: vscode.ExtensionContext) {
let microsoftSovereignCloudAuthProviderDisposable = await initMicrosoftSovereignCloudAuthProvider(context, telemetryReporter, uriHandler, betterSecretStorage);
context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(async e => {
if (e.affectsConfiguration('microsoft-sovereign-cloud.endpoint')) {
if (e.affectsConfiguration('microsoft-sovereign-cloud')) {
microsoftSovereignCloudAuthProviderDisposable?.dispose();
microsoftSovereignCloudAuthProviderDisposable = await initMicrosoftSovereignCloudAuthProvider(context, telemetryReporter, uriHandler, betterSecretStorage);
}

View file

@ -55,6 +55,11 @@
dependencies:
tslib "^2.2.0"
"@azure/ms-rest-azure-env@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@azure/ms-rest-azure-env/-/ms-rest-azure-env-2.0.0.tgz#45809f89763a480924e21d3c620cd40866771625"
integrity sha512-dG76W7ElfLi+fbTjnZVGj+M9e0BIEJmRxU6fHaUQ12bZBe8EJKYb2GV50YWNaP2uJiVQ5+7nXEVj1VN1UQtaEw==
"@microsoft/1ds-core-js@3.2.8", "@microsoft/1ds-core-js@^3.2.8":
version "3.2.8"
resolved "https://registry.yarnpkg.com/@microsoft/1ds-core-js/-/1ds-core-js-3.2.8.tgz#1b6b7d9bb858238c818ccf4e4b58ece7aeae5760"
@ -370,11 +375,6 @@ node-fetch@2.6.7:
dependencies:
whatwg-url "^5.0.0"
querystring-es3@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73"
integrity sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==
semver@^5.3.0, semver@^5.4.1:
version "5.7.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"