Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Microsoft Identity Platform aka Azure ActiveDirectory V2 #649

Merged
merged 15 commits into from
Aug 20, 2020
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,18 @@ System variables provide a pre-defined set of variables that can be used in any
`<domain|tenantId>`: Optional. Domain or tenant id for the directory to sign in to. Default: Pick a directory from a drop-down or press `Esc` to use the home directory (`common` for Microsoft Account).

`aud:<domain|tenantId>`: Optional. Target Azure AD app id (aka client id) or domain the token should be created for (aka audience or resource). Default: Domain of the REST endpoint.
* `{{$aadV2Token [new] [appOnly ][scopes:<scope[,]>] [tenantid:<domain|tenantId>] [clientid:<clientId>]}}`: Add an Azure Active Directory token based on the following options (must be specified in order):
darrelmiller marked this conversation as resolved.
Show resolved Hide resolved

`new`: Optional. Specify `new` to force re-authentication and get a new token for the specified directory. Default: Reuse previous token for the specified tenantId and clientId from an in-memory cache. Expired tokens are refreshed automatically. (Restart Visual Studio Code to clear the cache.)

`appOnly`: Optional. Specify appOnly to use make to use a client credentials flow to obtain a token. `aadV2ClientSecret` and `aadV2AppUri`must be provided as REST Client environment variables. `aadV2ClientId` and `aadV2TenantId` may also be optionally provided via the environment. `aadV2ClientId` in environment will only be used for `appOnly` calls.
darrelmiller marked this conversation as resolved.
Show resolved Hide resolved
darrelmiller marked this conversation as resolved.
Show resolved Hide resolved
darrelmiller marked this conversation as resolved.
Show resolved Hide resolved

`scopes:<scope[,]>`: Optional. Comma delimited list of scopes that must have consent to allow the call to be successful. Not applicable for `appOnly` calls.
darrelmiller marked this conversation as resolved.
Show resolved Hide resolved
darrelmiller marked this conversation as resolved.
Show resolved Hide resolved

`tenantId:<domain|tenantId>`: Optional. Domain or tenant id for the tenant to sign in to. (`common` to determine tenant from sign in).

`clientId:<clientid>`: Optional. Identifier of the application registration to use to obtain the token. Default uses an application registration created specifically for this plugin.

* `{{$guid}}`: Add a RFC 4122 v4 UUID
* `{{$processEnv [%]envVarName}}`: Allows the resolution of a local machine environment variable to a string value. A typical use case is for secret keys that you don't want to commit to source control.
For example: Define a shell environment variable in `.bashrc` or similar on windows
Expand Down
3 changes: 3 additions & 0 deletions src/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ export const DotenvVariableName = "$dotenv";
export const DotenvDescription = "Returns the environment value stored in a .env file";
export const AzureActiveDirectoryVariableName = "$aadToken";
export const AzureActiveDirectoryDescription = "Prompts to sign in to Azure AD and adds the token to the request";
export const AzureActiveDirectoryV2TokenVariableName = "$aadV2Token";
export const AzureActiveDirectoryV2TokenDescription = "Prompts to sign in to Azure AD V2 and adds the token to the request";

/**
* NOTE: The client id represents an AAD app people sign in to. The client id is sent to AAD to indicate what app
* is requesting a token for the user. When the user signs in, AAD shows the name of the app to confirm the user is
Expand Down
278 changes: 278 additions & 0 deletions src/utils/aadV2TokenProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
import { Clipboard, commands, env, Uri, window } from 'vscode';
import * as Constants from '../common/constants';
import { HttpRequest } from '../models/httpRequest';
import { HttpClient } from './httpClient';
import { EnvironmentVariableProvider } from './httpVariableProviders/environmentVariableProvider';

/*
AppId provisioned to allow users to explicitly consent to permissions that this app can call
*/
export const AadV2TokenProviderClientId = "07f0a107-95c1-41ad-8f13-912eab68b93f";
darrelmiller marked this conversation as resolved.
Show resolved Hide resolved

export class AadV2TokenProvider {
private readonly _httpClient: HttpClient;
private readonly clipboard: Clipboard;

public constructor() {
this._httpClient = new HttpClient();
this.clipboard = env.clipboard;
}

public async AcquireToken(name : string): Promise<string> {

const authParams = new AuthParameters();
await authParams.ParseName(name);

if (!authParams.forceNewToken) {
const tokenEntry = AadV2TokenCache.GetToken(authParams.GetCacheKey());
if (tokenEntry && tokenEntry.SupportScopes(authParams.scopes)) {
darrelmiller marked this conversation as resolved.
Show resolved Hide resolved
return tokenEntry.Token;
}
}

if (authParams.appOnly) {
return await this.GetConfidentialClientToken(authParams);
}

const deviceCodeResponse: IDeviceCodeResponse = await this.GetDeviceCodeResponse(authParams);
const isDone = await this.promptForUserCode(deviceCodeResponse);
if (isDone) {
return await this.GetToken(deviceCodeResponse, authParams);
} else {
return "";
}
}

private async GetDeviceCodeResponse(authParams: AuthParameters) : Promise<IDeviceCodeResponse> {
const request = this.createUserCodeRequest(authParams.clientId, authParams.tenantId, authParams.scopes);
const response = await this._httpClient.send(request);

const bodyObject = JSON.parse(response.body);

if (response.statusCode !== 200) {
// Fail
this.processAuthError(bodyObject);
}

if (bodyObject.error) { // really!?
this.processAuthError(bodyObject);
darrelmiller marked this conversation as resolved.
Show resolved Hide resolved
}

// Get userCode out of response body
const deviceCodeResponse: IDeviceCodeResponse = bodyObject;
darrelmiller marked this conversation as resolved.
Show resolved Hide resolved
return deviceCodeResponse;
}

private async GetToken(deviceCodeResponse: IDeviceCodeResponse, authParams: AuthParameters) : Promise<string> {
const request = this.createAcquireTokenRequest(authParams.clientId, authParams.tenantId, deviceCodeResponse.device_code);
const response = await this._httpClient.send(request);

const bodyObject = JSON.parse(response.body);

if (response.statusCode !== 200) {
this.processAuthError(bodyObject);
}
const tokenResponse: ITokenResponse = bodyObject;
AadV2TokenCache.SetToken(authParams.GetCacheKey(), tokenResponse.scope.split(' '), tokenResponse.access_token);

return tokenResponse.access_token;
}

private async GetConfidentialClientToken(authParams: AuthParameters): Promise<string> {
const request = this.createAcquireConfidentialClientTokenRequest(authParams.clientId, authParams.tenantId, authParams.clientSecret as string, authParams.appUri as string);
darrelmiller marked this conversation as resolved.
Show resolved Hide resolved
const response = await this._httpClient.send(request);

const bodyObject = JSON.parse(response.body);

if (response.statusCode !== 200) {
this.processAuthError(bodyObject);
}
const tokenResponse: ITokenResponse = bodyObject;
const scopes : string[] = [];
if (tokenResponse.scope) {
tokenResponse.scope.split(' ');
darrelmiller marked this conversation as resolved.
Show resolved Hide resolved
}
AadV2TokenCache.SetToken(authParams.GetCacheKey(), scopes, tokenResponse.access_token);
return tokenResponse.access_token;
}


private processAuthError(bodyObject: any) {
const errorResponse: IAuthError = bodyObject;
throw new Error(" Auth call failed. " + errorResponse.error_description);
darrelmiller marked this conversation as resolved.
Show resolved Hide resolved
}

private createUserCodeRequest(clientId: string, tenantId: string, scopes: string[]) : HttpRequest {
return new HttpRequest(
"POST", `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/devicecode`,
{ "Content-Type": "application/x-www-form-urlencoded" },
`client_id=${clientId}&scope=${scopes.join("%20")}`);
}

private createAcquireTokenRequest(clientId: string, tenantId: string, deviceCode: string) : HttpRequest {
return new HttpRequest("POST", `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`,
{ "Content-Type": "application/x-www-form-urlencoded" },
`grant_type=urn:ietf:params:oauth:grant-type:device_code&client_id=${clientId}&device_code=${deviceCode}`);
}

private createAcquireConfidentialClientTokenRequest(clientId: string, tenantId: string, clientSecret: string, appUri: string) : HttpRequest {
return new HttpRequest("POST", `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`,
{ "Content-Type": "application/x-www-form-urlencoded" },
`grant_type=client_credentials&client_id=${clientId}&client_secret=${clientSecret}&scope=${appUri}/.default`);
}

private async promptForUserCode(deviceCodeResponse: IDeviceCodeResponse) : Promise<boolean> {

const messageBoxOptions = { modal: true };
const signInPrompt = `Sign in to Azure AD with the following code (will be copied to the clipboard) to add a token to your request.\r\n\r\nCode: ${deviceCodeResponse.user_code}`;
const donePrompt = `1. Azure AD verification page opened in default browser (you may need to switch apps)\r\n2. Paste code to sign in and authorize VS Code (already copied to the clipboard)\r\n3. Confirm when done\r\n4. Token will be copied to the clipboard when finished\r\n\r\nCode: ${deviceCodeResponse.user_code}`;
const signIn = "Sign in";
const tryAgain = "Try again";
const done = "Done";

let value = await window.showInformationMessage(signInPrompt, messageBoxOptions, signIn);
if (value === signIn) {
do {
await this.clipboard.writeText(deviceCodeResponse.user_code);
commands.executeCommand("vscode.open", Uri.parse(deviceCodeResponse.verification_uri));
value = await window.showInformationMessage(donePrompt, messageBoxOptions, done, tryAgain);
} while (value === tryAgain);
}
return value === done;
}
}


/*

ClientId: We use default clientId for all delegated access unless overridden in $appToken. AppOnly access uses the one in the environment
TenantId: If not specified, we use common. If specified in environment, we use that. Value in $aadToken overrides
Scopes are always in $aadV2Token
*/
class AuthParameters {

private readonly aadV2TokenRegex: RegExp = new RegExp(`\\s*\\${Constants.AzureActiveDirectoryV2TokenVariableName}(\\s+(${Constants.AzureActiveDirectoryForceNewOption}))?(\\s+(appOnly))?(\\s+scopes:([\\w,.]+))?(\\s+tenantId:([^\\.]+\\.[^\\}\\s]+|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}))?(\\s+clientId:([^\\.]+\\.[^\\}\\s]+|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}))?\\s*`);
public tenantId: string;
public clientId: string;
public scopes: string[];
public forceNewToken: boolean;
public clientSecret?: string;
public appOnly: boolean;
public appUri?: string;

public constructor() {
this.clientId = AadV2TokenProviderClientId;
this.tenantId = "common";
this.forceNewToken = false;
this.appOnly = false;
}

async ReadEnvironmentVariable(variableName: string) : Promise<string | undefined> {
darrelmiller marked this conversation as resolved.
Show resolved Hide resolved
if (await EnvironmentVariableProvider.Instance.has(variableName)) {
const { value, error, warning } = await EnvironmentVariableProvider.Instance.get(variableName);
if (!warning && !error) {
return value as string;
} else {
return undefined;
}
}
return undefined;
}

GetCacheKey() : string {
return this.tenantId + "|" + this.clientId + "|" + this.appOnly as string;

}
async ParseName(name: string) {

// Update defaults based on environment
this.tenantId = (await this.ReadEnvironmentVariable("aadV2TenantId")) || this.tenantId;

let scopes = "openid,profile";
let explicitClientId: string|undefined = undefined;
darrelmiller marked this conversation as resolved.
Show resolved Hide resolved
// Parse variable parameters
const groups = this.aadV2TokenRegex.exec(name);
if (groups) {
this.forceNewToken = groups[2] === Constants.AzureActiveDirectoryForceNewOption;
this.appOnly = groups[4] === "appOnly";
scopes = groups[6] || scopes;
this.tenantId = groups[8] || this.tenantId;
explicitClientId = groups[10];
} else {
throw new Error("Failed to parse parameters: " + name);
}

this.scopes = scopes.split(",").map(s => s.trim());

if (this.appOnly) {
this.clientId = explicitClientId || (await this.ReadEnvironmentVariable("aadV2ClientId")) || this.clientId;
this.clientSecret = (await this.ReadEnvironmentVariable("aadV2ClientSecret"));
this.appUri = (await this.ReadEnvironmentVariable("aadV2AppUri"));
if (!(this.clientSecret && this.appUri)) {
throw new Error("For appOnly tokens, a environment variable aadV2ClientSecret and aadV2AppUri must be created. aadV2ClientId and aadV2TenantId are optional environment variables.");
darrelmiller marked this conversation as resolved.
Show resolved Hide resolved
}
} else {
this.clientId = explicitClientId || this.clientId;
}
}
}

class AadV2TokenCache {

private static tokens : Map<string, AadV2TokenCacheEntry> = new Map<string, AadV2TokenCacheEntry>();
darrelmiller marked this conversation as resolved.
Show resolved Hide resolved

static SetToken(cacheKey: string, scopes: string[], token: string) {
darrelmiller marked this conversation as resolved.
Show resolved Hide resolved

const entry : AadV2TokenCacheEntry = new AadV2TokenCacheEntry();
entry.Token = token;
entry.Scopes = scopes;
this.tokens.set(cacheKey, entry);
}
static GetToken(cacheKey: string) : AadV2TokenCacheEntry | undefined {
darrelmiller marked this conversation as resolved.
Show resolved Hide resolved
return this.tokens.get(cacheKey);
}
}

class AadV2TokenCacheEntry {
public Token: string;
darrelmiller marked this conversation as resolved.
Show resolved Hide resolved
public Scopes: string[];

SupportScopes(scopes: string[]) : boolean {
darrelmiller marked this conversation as resolved.
Show resolved Hide resolved
let found: boolean = true;
darrelmiller marked this conversation as resolved.
Show resolved Hide resolved
scopes.forEach(element => {
if (!this.Scopes.includes(element)) {
found = false;
return;
}
});
return found;
}
}

interface IAuthError {
error: string;
error_description: string;
error_uri: string;
error_codes: number[];
timestamp: string;
trace_id: string;
correlation_id: string;
}

interface IDeviceCodeResponse {
user_code: string;
device_code: string;
verification_uri: string;
expires_in: string;
interval: string;
message: string;
}

interface ITokenResponse {
token_type: string;
scope: string;
expires_in: number;
access_token: string;
refresh_token: string;
id_token: string;
}
10 changes: 10 additions & 0 deletions src/utils/httpVariableProviders/systemVariableProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { HttpRequest } from '../../models/httpRequest';
import { ResolveErrorMessage, ResolveWarningMessage } from '../../models/httpVariableResolveResult';
import { VariableType } from '../../models/variableType';
import { AadTokenCache } from '../aadTokenCache';
import { AadV2TokenProvider } from '../aadV2TokenProvider';
import { HttpClient } from '../httpClient';
import { EnvironmentVariableProvider } from './environmentVariableProvider';
import { HttpVariable, HttpVariableContext, HttpVariableProvider } from './httpVariableProvider';
Expand Down Expand Up @@ -58,6 +59,7 @@ export class SystemVariableProvider implements HttpVariableProvider {
this.registerProcessEnvVariable();
this.registerDotenvVariable();
this.registerAadTokenVariable();
this.registerAadV2TokenVariable();
}

public readonly type: VariableType = VariableType.System;
Expand Down Expand Up @@ -278,6 +280,14 @@ export class SystemVariableProvider implements HttpVariableProvider {
});
}

private registerAadV2TokenVariable() {
this.resolveFuncs.set(Constants.AzureActiveDirectoryV2TokenVariableName,
async (name) => {
const aadV2TokenProvider = new AadV2TokenProvider();
darrelmiller marked this conversation as resolved.
Show resolved Hide resolved
const token = await aadV2TokenProvider.AcquireToken(name);
return {value: token};
});
}
private async resolveSettingsEnvironmentVariable(name: string) {
if (await this.innerSettingsEnvironmentVariableProvider.has(name)) {
const { value, error, warning } = await this.innerSettingsEnvironmentVariableProvider.get(name);
Expand Down