Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
176 changes: 150 additions & 26 deletions common/config/rush/pnpm-lock.yaml

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions sdk/identity/identity/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Release History

## 1.2.0-beta.1 (2020-09-08)

- A new `InteractiveBrowserCredential` for node which will spawn a web server, start a web browser, and allow the user to interactively authenticate with the browser.
- With 1.2.0-beta.1, Identity will now use [MSAL](https://www.npmjs.com/package/@azure/msal-node) to perform authentication. With this beta, DeviceCodeCredential and a new InteractiveBrowserCredential for node are powered by MSAL.
- Identity now supports Subject Name/Issuer (SNI) as part of authentication for ClientCertificateCredential
- Upgraded App Services MSI API version

## 1.1.0 (2020-08-11)

### Changes since 1.0.*
Expand Down
8 changes: 5 additions & 3 deletions sdk/identity/identity/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@azure/identity",
"sdk-type": "client",
"version": "1.1.0",
"version": "1.2.0-beta.1",
"description": "Provides credential implementations for Azure SDK libraries that can authenticate with Azure Active Directory",
"main": "dist/index.js",
"module": "dist-esm/src/index.js",
Expand Down Expand Up @@ -82,11 +82,15 @@
"@azure/core-http": "^1.1.6",
"@azure/core-tracing": "1.0.0-preview.9",
"@azure/logger": "^1.0.0",
"@azure/msal-node": "^1.0.0-alpha.5",
"@opentelemetry/api": "^0.10.2",
"events": "^3.0.0",
"jws": "^4.0.0",
"msal": "^1.0.2",
"open": "^7.0.0",
"qs": "^6.7.0",
"@rollup/plugin-json": "^4.0.0",
"axios": "^0.19.2",
"tslib": "^2.0.0",
"uuid": "^8.1.0"
},
Expand All @@ -98,7 +102,6 @@
"@azure/abort-controller": "^1.0.0",
"@microsoft/api-extractor": "7.7.11",
"@rollup/plugin-commonjs": "11.0.2",
"@rollup/plugin-json": "^4.0.0",
"@rollup/plugin-multi-entry": "^3.0.0",
"@rollup/plugin-node-resolve": "^8.0.0",
"@rollup/plugin-replace": "^2.2.0",
Expand All @@ -125,7 +128,6 @@
"karma-remap-istanbul": "^0.6.0",
"mocha": "^7.1.1",
"mocha-junit-reporter": "^1.18.0",
"open": "^7.0.0",
"prettier": "^1.16.4",
"puppeteer": "^3.3.0",
"rimraf": "^3.0.0",
Expand Down
2 changes: 2 additions & 0 deletions sdk/identity/identity/rollup.base.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import nodeResolve from "@rollup/plugin-node-resolve";
import multiEntry from "@rollup/plugin-multi-entry";
import cjs from "@rollup/plugin-commonjs";
import json from "@rollup/plugin-json";
import replace from "@rollup/plugin-replace";
import { terser } from "rollup-plugin-terser";
import sourcemaps from "rollup-plugin-sourcemaps";
Expand All @@ -27,6 +28,7 @@ export function nodeConfig(test = false) {
"if (isNode)": "if (true)"
}),
nodeResolve({ preferBuiltins: true }),
json(),
cjs()
]
};
Expand Down
226 changes: 45 additions & 181 deletions sdk/identity/identity/src/credentials/deviceCodeCredential.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,13 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import qs from "qs";
import { TokenCredential, GetTokenOptions, AccessToken } from "@azure/core-http";
import * as coreHttp from "@azure/core-http";
import { IdentityClient, TokenResponse, TokenCredentialOptions } from "../client/identityClient";
import { AuthenticationError, AuthenticationErrorName } from "../client/errors";
import { AccessToken, TokenCredential, GetTokenOptions, delay } from "@azure/core-http";
import { TokenCredentialOptions, IdentityClient } from "../client/identityClient";
import { createSpan } from "../util/tracing";
import { CanonicalCode } from "@opentelemetry/api";
import { credentialLogger, formatSuccess } from "../util/logging";
import { AuthenticationError, AuthenticationErrorName } from "../client/errors";
import { CanonicalCode } from "@opentelemetry/api";

/**
* An internal interface that contains the verbatim devicecode response.
* This interface does not get exported from the public interface of the
* library.
*/
export interface DeviceCodeResponse {
device_code: string;
user_code: string;
verification_uri: string;
expires_in: number;
interval: number;
message: string;
}
import { PublicClientApplication, DeviceCodeRequest } from "@azure/msal-node";

/**
* Provides the user code and verification URI where the code must be
Expand Down Expand Up @@ -63,10 +48,11 @@ const logger = credentialLogger("DeviceCodeCredential");
*/
export class DeviceCodeCredential implements TokenCredential {
private identityClient: IdentityClient;
private pca: PublicClientApplication;
private tenantId: string;
private clientId: string;
private userPromptCallback: DeviceCodePromptCallback;
private lastTokenResponse: TokenResponse | null = null;
private authorityHost: string;

/**
* Creates an instance of DeviceCodeCredential with the details needed
Expand All @@ -89,138 +75,27 @@ export class DeviceCodeCredential implements TokenCredential {
this.tenantId = tenantId;
this.clientId = clientId;
this.userPromptCallback = userPromptCallback;
}

private async sendDeviceCodeRequest(
scope: string,
options?: GetTokenOptions
): Promise<DeviceCodeResponse> {
const { span, options: newOptions } = createSpan(
"DeviceCodeCredential-sendDeviceCodeRequest",
options
);
try {
const webResource = this.identityClient.createWebResource({
url: `${this.identityClient.authorityHost}/${this.tenantId}/oauth2/v2.0/devicecode`,
method: "POST",
disableJsonStringifyOnBody: true,
deserializationMapper: undefined,
body: qs.stringify({
client_id: this.clientId,
scope
}),
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded"
},
abortSignal: options && options.abortSignal,
spanOptions: newOptions.tracingOptions && newOptions.tracingOptions.spanOptions
});

logger.info("Sending devicecode request");

const response = await this.identityClient.sendRequest(webResource);
if (!(response.status === 200 || response.status === 201)) {
throw new AuthenticationError(response.status, response.bodyAsText);
}

return response.parsedBody as DeviceCodeResponse;
} catch (err) {
const code =
err.name === AuthenticationErrorName
? CanonicalCode.UNAUTHENTICATED
: CanonicalCode.UNKNOWN;

if (err.name === AuthenticationErrorName) {
logger.info(
`Failed to authenticate ${(err as AuthenticationError).errorResponse.errorDescription}`
);
if (options && options.authorityHost) {
if (options.authorityHost.endsWith("/")) {
this.authorityHost = options.authorityHost + this.tenantId;
} else {
logger.info(`Failed to authenticate ${err}`);
this.authorityHost = options.authorityHost + "/" + this.tenantId;
}

span.setStatus({
code,
message: err.message
});
throw err;
} finally {
span.end();
} else {
this.authorityHost = "https://login.microsoftonline.com/" + this.tenantId;
}
}

private async pollForToken(
deviceCodeResponse: DeviceCodeResponse,
options?: GetTokenOptions
): Promise<TokenResponse | null> {
let tokenResponse: TokenResponse | null = null;
const { span, options: newOptions } = createSpan("DeviceCodeCredential-pollForToken", options);

try {
const webResource = this.identityClient.createWebResource({
url: `${this.identityClient.authorityHost}/${this.tenantId}/oauth2/v2.0/token`,
method: "POST",
disableJsonStringifyOnBody: true,
deserializationMapper: undefined,
body: qs.stringify({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
client_id: this.clientId,
device_code: deviceCodeResponse.device_code
}),
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded"
},
abortSignal: options && options.abortSignal,
spanOptions: newOptions.tracingOptions && newOptions.tracingOptions.spanOptions
});

while (tokenResponse === null) {
try {
// Referencing delay from core-http this way for testing purposes.
await coreHttp.delay(deviceCodeResponse.interval * 1000);

// Check the abort signal before sending the request
if (options && options.abortSignal && options.abortSignal.aborted) {
return null;
}

tokenResponse = await this.identityClient.sendTokenRequest(webResource);
} catch (err) {
if (err.name === AuthenticationErrorName) {
switch (err.errorResponse.error) {
case "authorization_pending":
break;
case "authorization_declined":
return null;
case "expired_token":
throw err;
case "bad_verification_code":
throw err;
default:
// Any other error should be rethrown
throw err;
}
} else {
throw err;
}
}
}

return tokenResponse;
} catch (err) {
const code =
err.name === AuthenticationErrorName
? CanonicalCode.UNAUTHENTICATED
: CanonicalCode.UNKNOWN;
span.setStatus({
code,
message: err.message
});
throw err;
} finally {
span.end();
}
const publicClientConfig = {
auth: {
clientId: this.clientId,
authority: this.authorityHost,
},
cache: {
cachePlugin: undefined
},
};

this.pca = new PublicClientApplication(publicClientConfig);
}

/**
Expand All @@ -233,46 +108,23 @@ export class DeviceCodeCredential implements TokenCredential {
* @param options The options used to configure any requests this
* TokenCredential implementation might make.
*/
public async getToken(
getToken(
scopes: string | string[],
options?: GetTokenOptions
): Promise<AccessToken | null> {
const { span, options: newOptions } = createSpan("DeviceCodeCredential-getToken", options);
try {
let tokenResponse: TokenResponse | null = null;
let scopeString = typeof scopes === "string" ? scopes : scopes.join(" ");
if (scopeString.indexOf("offline_access") < 0) {
scopeString += " offline_access";
}

// Try to use the refresh token first
if (this.lastTokenResponse && this.lastTokenResponse.refreshToken) {
tokenResponse = await this.identityClient.refreshAccessToken(
this.tenantId,
this.clientId,
scopeString,
this.lastTokenResponse.refreshToken,
undefined, // clientSecret not needed for device code auth
undefined,
newOptions
);
}

if (tokenResponse === null) {
const deviceCodeResponse = await this.sendDeviceCodeRequest(scopeString, newOptions);
const scopeArray = typeof scopes === "object" ? scopes : [scopes];

this.userPromptCallback({
userCode: deviceCodeResponse.user_code,
verificationUri: deviceCodeResponse.verification_uri,
message: deviceCodeResponse.message
});
const deviceCodeRequest = {
deviceCodeCallback: this.userPromptCallback,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm assuming our DeviceCodePromptCallback type has the same shape as the what's expected by deviceCodeCallback?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not quite sure what you're asking here

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm saying the DeviceCodeCredential takes userPromptCallback: DeviceCodePromptCallback, on construction. Here we're passing the callback directly to acquireTokenByDeviceCode. These callbacks have the same shape? Mostly I wanted to make sure we weren't exporting types from MSAL.

Copy link
Copy Markdown
Contributor Author

@sophiajt sophiajt Sep 2, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, they're different though compatible in this direction.

Here's the MSAL version:

export declare type DeviceCodeResponse = {
    userCode: string;
    deviceCode: string;
    verificationUri: string;
    expiresIn: number;
    interval: number;
    message: string;
};

And here's the identity version:

export interface DeviceCodeInfo {
  /**
   * The device code that the user must enter into the verification page.
   */
  userCode: string;

  /**
   * The verification URI to which the user must navigate to enter the device
   * code.
   */
  verificationUri: string;

  /**
   * A message that may be shown to the user to instruct them on how to enter
   * the device code in the page specified by the verification URI.
   */
  message: string;
}

scopes: scopeArray,
};

tokenResponse = await this.pollForToken(deviceCodeResponse, newOptions);
}
logger.info("Sending devicecode request");

this.lastTokenResponse = tokenResponse;
logger.getToken.info(formatSuccess(scopes));
return (tokenResponse && tokenResponse.accessToken) || null;
try {
return this.acquireTokenByDeviceCode(deviceCodeRequest);
} catch (err) {
const code =
err.name === AuthenticationErrorName
Expand All @@ -288,4 +140,16 @@ export class DeviceCodeCredential implements TokenCredential {
span.end();
}
}

private async acquireTokenByDeviceCode(deviceCodeRequest: DeviceCodeRequest): Promise<AccessToken | null> {
try {
const deviceResponse = await this.pca.acquireTokenByDeviceCode(deviceCodeRequest);
return({
expiresOnTimestamp: deviceResponse.expiresOn.getTime(),
token: deviceResponse.accessToken
});
} catch (error) {
throw new Error(`Device Authentication Error "${JSON.stringify(error)}"`);
}
}
}
Loading