Skip to content

Commit

Permalink
Support sovereign/custom clouds in microsoft authentication provider (
Browse files Browse the repository at this point in the history
  • Loading branch information
bwateratmsft authored Apr 7, 2023
1 parent d74f53e commit f9d14d6
Show file tree
Hide file tree
Showing 7 changed files with 222 additions and 48 deletions.
2 changes: 2 additions & 0 deletions extensions/microsoft-authentication/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@
## Features

This extension provides support for authenticating to Microsoft. It registers the `microsoft` Authentication Provider that can be leveraged by other extensions. This also provides the Microsoft authentication used by Settings Sync.

Additionally, it provides the `microsoft-sovereign-cloud` Authentication Provider that can be used to sign in to other Azure clouds like Azure for US Government or Azure China. Use the setting `microsoft-sovereign-cloud.endpoint` to select the authentication endpoint the provider should use. Please note that different scopes may also be required in different environments.
26 changes: 26 additions & 0 deletions extensions/microsoft-authentication/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,32 @@
{
"label": "Microsoft",
"id": "microsoft"
},
{
"label": "Microsoft Sovereign Cloud",
"id": "microsoft-sovereign-cloud"
}
],
"configuration": [
{
"title": "Microsoft Sovereign Cloud",
"properties": {
"microsoft-sovereign-cloud.endpoint": {
"anyOf": [
{
"type": "string"
},
{
"type": "string",
"enum": [
"Azure China",
"Azure US Government"
]
}
],
"description": "%microsoft-sovereign-cloud.endpoint.description%"
}
}
}
]
},
Expand Down
3 changes: 2 additions & 1 deletion extensions/microsoft-authentication/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
"displayName": "Microsoft Account",
"description": "Microsoft authentication provider",
"signIn": "Sign In",
"signOut": "Sign Out"
"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."
}
102 changes: 70 additions & 32 deletions extensions/microsoft-authentication/src/AADHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ import { BetterTokenStorage, IDidChangeInOtherWindowEvent } from './betterSecret
import { LoopbackAuthServer } from './node/authServer';
import { base64Decode } from './node/buffer';
import { fetching } from './node/fetch';
import { UriEventHandler } from './UriEventHandler';

const redirectUrl = 'https://vscode.dev/redirect';
const loginEndpointUrl = 'https://login.microsoftonline.com/';
const defaultLoginEndpointUrl = 'https://login.microsoftonline.com/';
const DEFAULT_CLIENT_ID = 'aebc6443-996d-45c2-90f0-388ff96faa56';
const DEFAULT_TENANT = 'organizations';

Expand All @@ -35,7 +36,7 @@ interface IToken {
sessionId: string; // The account id + the scope
}

interface IStoredSession {
export interface IStoredSession {
id: string;
refreshToken: string;
scope: string; // Scopes are alphabetized and joined with a space
Expand All @@ -44,6 +45,7 @@ interface IStoredSession {
displayName?: string;
id: string;
};
endpoint: string | undefined;
}

export interface ITokenResponse {
Expand All @@ -70,42 +72,34 @@ interface IScopeData {
tenant: string;
}

export const onDidChangeSessions = new vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();

export const REFRESH_NETWORK_FAILURE = 'Network failure';

class UriEventHandler extends vscode.EventEmitter<vscode.Uri> implements vscode.UriHandler {
public handleUri(uri: vscode.Uri) {
this.fire(uri);
}
}

export class AzureActiveDirectoryService {
// For details on why this is set to 2/3... see https://github.com/microsoft/vscode/issues/133201#issuecomment-966668197
private static REFRESH_TIMEOUT_MODIFIER = 1000 * 2 / 3;
private static POLLING_CONSTANT = 1000 * 60 * 30;
private _tokens: IToken[] = [];
private _refreshTimeouts: Map<string, NodeJS.Timeout> = new Map<string, NodeJS.Timeout>();
private _refreshingPromise: Promise<any> | undefined;
private _uriHandler: UriEventHandler;
private _sessionChangeEmitter: vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent> = new vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();

// Used to keep track of current requests when not using the local server approach.
private _pendingNonces = new Map<string, string[]>();
private _codeExchangePromises = new Map<string, Promise<vscode.AuthenticationSession>>();
private _codeVerfifiers = new Map<string, string>();

private readonly _tokenStorage: BetterTokenStorage<IStoredSession>;

constructor(private _context: vscode.ExtensionContext) {
this._tokenStorage = new BetterTokenStorage('microsoft.login.keylist', _context);
this._uriHandler = new UriEventHandler();
_context.subscriptions.push(vscode.window.registerUriHandler(this._uriHandler));
constructor(
private readonly _context: vscode.ExtensionContext,
private readonly _uriHandler: UriEventHandler,
private readonly _tokenStorage: BetterTokenStorage<IStoredSession>,
private readonly _loginEndpointUrl: string = defaultLoginEndpointUrl
) {
_context.subscriptions.push(this._tokenStorage.onDidChangeInOtherWindow((e) => this.checkForUpdates(e)));
}

public async initialize(): Promise<void> {
Logger.info('Reading sessions from secret storage...');
const sessions = await this._tokenStorage.getAll();
const sessions = await this._tokenStorage.getAll(item => this.sessionMatchesEndpoint(item));
Logger.info(`Got ${sessions.length} stored sessions`);

const refreshes = sessions.map(async session => {
Expand Down Expand Up @@ -162,6 +156,10 @@ export class AzureActiveDirectoryService {

//#region session operations

public get onDidChangeSessions(): vscode.Event<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent> {
return this._sessionChangeEmitter.event;
}

async getSessions(scopes?: string[]): Promise<vscode.AuthenticationSession[]> {
if (!scopes) {
Logger.info('Getting sessions for all scopes...');
Expand Down Expand Up @@ -246,7 +244,7 @@ export class AzureActiveDirectoryService {
return Promise.all(matchingTokens.map(token => this.convertToSession(token, scopeData)));
}

public createSession(scopes: string[]): Promise<vscode.AuthenticationSession> {
public async createSession(scopes: string[]): Promise<vscode.AuthenticationSession> {
let modifiedScopes = [...scopes];
if (!modifiedScopes.includes('openid')) {
modifiedScopes.push('openid');
Expand Down Expand Up @@ -275,12 +273,19 @@ 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) {
throw new Error('Sign in to non-public Azure clouds is not supported on the web.');
}

if (runsRemote || runsServerless) {
return this.createSessionWithoutLocalServer(scopeData);
}

try {
return this.createSessionWithLocalServer(scopeData);
const session = await this.createSessionWithLocalServer(scopeData);
this._sessionChangeEmitter.fire({ added: [session], removed: [], changed: [] });
return session;
} catch (e) {
Logger.error(`Error creating session for scopes: ${scopeData.scopeStr} Error: ${e}`);

Expand All @@ -306,7 +311,7 @@ export class AzureActiveDirectoryService {
code_challenge_method: 'S256',
code_challenge: codeChallenge,
}).toString();
const loginUrl = `${loginEndpointUrl}${scopeData.tenant}/oauth2/v2.0/authorize?${qs}`;
const loginUrl = `${this._loginEndpointUrl}${scopeData.tenant}/oauth2/v2.0/authorize?${qs}`;
const server = new LoopbackAuthServer(path.join(__dirname, '../media'), loginUrl);
await server.start();

Expand Down Expand Up @@ -336,7 +341,7 @@ export class AzureActiveDirectoryService {
const state = encodeURIComponent(callbackUri.toString(true));
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const signInUrl = `${loginEndpointUrl}${scopeData.tenant}/oauth2/v2.0/authorize`;
const signInUrl = `${this._loginEndpointUrl}${scopeData.tenant}/oauth2/v2.0/authorize`;
const oauthStartQuery = new URLSearchParams({
response_type: 'code',
client_id: encodeURIComponent(scopeData.clientId),
Expand Down Expand Up @@ -386,7 +391,7 @@ export class AzureActiveDirectoryService {
});
}

public removeSessionById(sessionId: string, writeToDisk: boolean = true): Promise<vscode.AuthenticationSession | undefined> {
public async removeSessionById(sessionId: string, writeToDisk: boolean = true): Promise<vscode.AuthenticationSession | undefined> {
Logger.info(`Logging out of session '${sessionId}'`);
const tokenIndex = this._tokens.findIndex(token => token.sessionId === sessionId);
if (tokenIndex === -1) {
Expand All @@ -395,13 +400,19 @@ export class AzureActiveDirectoryService {
}

const token = this._tokens.splice(tokenIndex, 1)[0];
return this.removeSessionByIToken(token, writeToDisk);
const session = await this.removeSessionByIToken(token, writeToDisk);

if (session) {
this._sessionChangeEmitter.fire({ added: [], removed: [session], changed: [] });
}

return session;
}

public async clearSessions() {
Logger.info('Logging out of all sessions');
this._tokens = [];
await this._tokenStorage.deleteAll();
await this._tokenStorage.deleteAll(item => this.sessionMatchesEndpoint(item));

this._refreshTimeouts.forEach(timeout => {
clearTimeout(timeout);
Expand All @@ -424,7 +435,7 @@ export class AzureActiveDirectoryService {

const session = this.convertToSessionSync(token);
Logger.info(`Sending change event for session that was removed with scopes: ${token.scope}`);
onDidChangeSessions.fire({ added: [], removed: [session], changed: [] });
this._sessionChangeEmitter.fire({ added: [], removed: [session], changed: [] });
Logger.info(`Logged out of session '${token.sessionId}' with scopes: ${token.scope}`);
return session;
}
Expand All @@ -439,7 +450,7 @@ export class AzureActiveDirectoryService {
try {
const refreshedToken = await this.refreshToken(refreshToken, scopeData, sessionId);
Logger.info('Triggering change session event...');
onDidChangeSessions.fire({ added: [], removed: [], changed: [this.convertToSessionSync(refreshedToken)] });
this._sessionChangeEmitter.fire({ added: [], removed: [], changed: [this.convertToSessionSync(refreshedToken)] });
} catch (e) {
if (e.message !== REFRESH_NETWORK_FAILURE) {
vscode.window.showErrorMessage(vscode.l10n.t('You have been signed out because reading stored authentication information failed.'));
Expand Down Expand Up @@ -570,7 +581,7 @@ export class AzureActiveDirectoryService {
});

const proxyEndpoints: { [providerId: string]: string } | undefined = await vscode.commands.executeCommand('workbench.getCodeExchangeProxyEndpoints');
const endpointUrl = proxyEndpoints?.microsoft || loginEndpointUrl;
const endpointUrl = proxyEndpoints?.microsoft || this._loginEndpointUrl;
const endpoint = `${endpointUrl}${scopeData.tenant}/oauth2/v2.0/token`;

try {
Expand Down Expand Up @@ -705,8 +716,16 @@ export class AzureActiveDirectoryService {
redirect_uri: redirectUrl
});

const proxyEndpoints: { [providerId: string]: string } | undefined = await vscode.commands.executeCommand('workbench.getCodeExchangeProxyEndpoints');
const endpointUrl = proxyEndpoints?.microsoft || loginEndpointUrl;
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);
Expand Down Expand Up @@ -821,7 +840,8 @@ export class AzureActiveDirectoryService {
id: token.sessionId,
refreshToken: token.refreshToken,
scope: token.scope,
account: token.account
account: token.account,
endpoint: this._loginEndpointUrl,
});
Logger.info(`Stored token for scopes: ${scopeData.scopeStr}`);
}
Expand All @@ -833,6 +853,12 @@ export class AzureActiveDirectoryService {
Logger.error('session not found that was apparently just added');
return;
}

if (!this.sessionMatchesEndpoint(session)) {
// If the session wasn't made for this login endpoint, ignore this update
continue;
}

const matchesExisting = this._tokens.some(token => token.scope === session.scope && token.sessionId === session.id);
if (!matchesExisting && session.refreshToken) {
try {
Expand All @@ -848,7 +874,7 @@ export class AzureActiveDirectoryService {
Logger.info(`Session added in another window with scopes: ${session.scope}`);
const token = await this.refreshToken(session.refreshToken, scopeData, session.id);
Logger.info(`Sending change event for session that was added with scopes: ${scopeData.scopeStr}`);
onDidChangeSessions.fire({ added: [this.convertToSessionSync(token)], removed: [], changed: [] });
this._sessionChangeEmitter.fire({ added: [this.convertToSessionSync(token)], removed: [], changed: [] });
return;
} catch (e) {
// Network failures will automatically retry on next poll.
Expand All @@ -862,6 +888,11 @@ export class AzureActiveDirectoryService {
}

for (const { value } of e.removed) {
if (!this.sessionMatchesEndpoint(value)) {
// If the session wasn't made for this login endpoint, ignore this update
continue;
}

Logger.info(`Session removed in another window with scopes: ${value.scope}`);
await this.removeSessionById(value.id, false);
}
Expand All @@ -872,6 +903,13 @@ export class AzureActiveDirectoryService {
// are already managing (see usages of `setSessionTimeout`).
}

private sessionMatchesEndpoint(session: IStoredSession): boolean {
// For older sessions with no endpoint set, it can be assumed to be the default endpoint
session.endpoint ||= defaultLoginEndpointUrl;

return session.endpoint === this._loginEndpointUrl;
}

//#endregion

//#region static methods
Expand Down
12 changes: 12 additions & 0 deletions extensions/microsoft-authentication/src/UriEventHandler.ts
Original file line number Diff line number Diff line change
@@ -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.
*--------------------------------------------------------------------------------------------*/

import * as vscode from 'vscode';

export class UriEventHandler extends vscode.EventEmitter<vscode.Uri> implements vscode.UriHandler {
public handleUri(uri: vscode.Uri) {
this.fire(uri);
}
}
14 changes: 9 additions & 5 deletions extensions/microsoft-authentication/src/betterSecretStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,13 @@ export class BetterTokenStorage<T> {
return tokens.get(key);
}

async getAll(): Promise<T[]> {
async getAll(predicate?: (item: T) => boolean): Promise<T[]> {
const tokens = await this.getTokens();
const values = new Array<T>();
for (const [_, value] of tokens) {
values.push(value);
if (!predicate || predicate(value)) {
values.push(value);
}
}
return values;
}
Expand Down Expand Up @@ -141,11 +143,13 @@ export class BetterTokenStorage<T> {
this._operationInProgress = false;
}

async deleteAll(): Promise<void> {
async deleteAll(predicate?: (item: T) => boolean): Promise<void> {
const tokens = await this.getTokens();
const promises = [];
for (const [key] of tokens) {
promises.push(this.delete(key));
for (const [key, value] of tokens) {
if (!predicate || predicate(value)) {
promises.push(this.delete(key));
}
}
await Promise.all(promises);
}
Expand Down
Loading

0 comments on commit f9d14d6

Please sign in to comment.