Skip to content

Commit

Permalink
Add support for Microsoft Identity Platform aka Azure ActiveDirectory…
Browse files Browse the repository at this point in the history
… V2 (#649)

* Setup token and first call

* GraphToken is working

* Made it async

* Added support for appOnly calls

* Fixed linting errors

* Updates based on PR review

* Fixed null test operator

* Fixed remaining syntax and formatting issues

* Update README.md for aadV2token

* Update README.md for aadV2token

* Update src/utils/aadV2TokenProvider.ts, remove space.

* Fixed additional issues based on reviews

* Added description for AADv2

* Fixed broken linting

Co-authored-by: Darrel Miller <[email protected]>
  • Loading branch information
darrelmiller and darrelmiller authored Aug 20, 2020
1 parent a2601f7 commit 064c1d7
Show file tree
Hide file tree
Showing 4 changed files with 300 additions and 0 deletions.
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ REST Client allows you to send HTTP request and view the response in Visual Stud
- Digest Auth
- SSL Client Certificates
- Azure Active Directory
- Microsoft Identity Platform
- AWS Signature v4
* Environments and custom/system variables support
- Use variables in any place of request(_URL_, _Headers_, _Body_)
Expand Down Expand Up @@ -362,6 +363,9 @@ Or if you have certificate in `PFX` or `PKCS12` format, setting code can be like
### Azure Active Directory(Azure AD)
Azure AD is Microsoft’s multi-tenant, cloud-based directory and identity management service, you can refer to the [System Variables](#system-variables) section for more details.

### Microsoft Identity Platform(Azure AD V2)
Microsoft identity platform is an evolution of the Azure Active Directory (Azure AD) developer platform. It allows developers to build applications that sign in all Microsoft identities and get tokens to call Microsoft APIs such as Microsoft Graph or APIs that developers have built. Microsoft Identity platform supports OAuth2 scopes, incremental consent and advanced features like multi-factor authentication and conditional access.

### AWS Signature v4
AWS Signature version 4 authenticates requests to AWS services. To use it you need to set the Authorization header schema to `AWS` and provide your AWS credentials separated by spaces:
- `<accessId>`: AWS Access Key Id
Expand Down Expand Up @@ -539,6 +543,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):

`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.

`scopes:<scope[,]>`: Optional. Comma delimited list of scopes that must have consent to allow the call to be successful. Not applicable for `appOnly` calls.

`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
271 changes: 271 additions & 0 deletions src/utils/aadV2TokenProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
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
*/
const AadV2TokenProviderClientId = "07f0a107-95c1-41ad-8f13-912eab68b93f";

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 = await AuthParameters.parseName(name);

if (!authParams.forceNewToken) {
const tokenEntry = AadV2TokenCache.getToken(authParams.getCacheKey());
if (tokenEntry?.supportScopes(authParams.scopes)) {
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.processAuthErrorAndThrow(bodyObject);
}

if (bodyObject.error) { // This is only needed due to an error in AADV2 device code endpoint. An issue is filed.
this.processAuthErrorAndThrow(bodyObject);
}

// Get userCode out of response body
return bodyObject as IDeviceCodeResponse;
}

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.processAuthErrorAndThrow(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!, authParams.appUri!);
const response = await this._httpClient.send(request);

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

if (response.statusCode !== 200) {
this.processAuthErrorAndThrow(bodyObject);
}
const tokenResponse: ITokenResponse = bodyObject;
const scopes : string[] = []; // Confidential Client tokens are limited to scopes defined in the app registration portal
AadV2TokenCache.setToken(authParams.getCacheKey(), scopes, tokenResponse.access_token);
return tokenResponse.access_token;
}

private processAuthErrorAndThrow(bodyObject: any) {
const errorResponse: IAuthError = bodyObject;
throw new Error("Auth call failed. " + errorResponse.error_description);
}

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 for delegated access. They are not used for appOnly.
*/
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> {
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;
}

static async parseName(name: string): Promise<AuthParameters> {

const authParameters = new AuthParameters();

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

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

// if scopes does not contain openid or profile, add it
// Using /common endpoint with only organizational scopes causes device code to fail.
// Adding openid and/or profile prevents this failure from occuring
if (scopes.indexOf("openid") === -1) {
scopes += ",openid,profile";
}
authParameters.scopes = scopes.split(",").map(s => s.trim());

if (authParameters.appOnly) {
authParameters.clientId = explicitClientId || (await authParameters.readEnvironmentVariable("aadV2ClientId")) || authParameters.clientId;
authParameters.clientSecret = await authParameters.readEnvironmentVariable("aadV2ClientSecret");
authParameters.appUri = await authParameters.readEnvironmentVariable("aadV2AppUri");
if (!(authParameters.clientSecret && authParameters.appUri)) {
throw new Error("For appOnly tokens, environment variables aadV2ClientSecret and aadV2AppUri must be created. aadV2ClientId and aadV2TenantId are optional environment variables.");
}
} else {
authParameters.clientId = explicitClientId || authParameters.clientId;
}
return authParameters;
}
}

class AadV2TokenCache {

private static tokens: Map<string, AadV2TokenCacheEntry> = new Map<string, AadV2TokenCacheEntry>();

public static setToken(cacheKey: string, scopes: string[], token: string) {
const entry: AadV2TokenCacheEntry = new AadV2TokenCacheEntry();
entry.token = token;
entry.scopes = scopes;
this.tokens.set(cacheKey, entry);
}

public static getToken(cacheKey: string) : AadV2TokenCacheEntry | undefined {
return this.tokens.get(cacheKey);
}
}

class AadV2TokenCacheEntry {
public token: string;
public scopes: string[];
public supportScopes(scopes: string[]) : boolean {
return scopes.every((scope) => this.scopes.includes(scope));
}
}

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();
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

0 comments on commit 064c1d7

Please sign in to comment.