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

[Identity] Simple OnBehalfOfCredential #17137

Merged
merged 11 commits into from
Sep 8, 2021
2 changes: 2 additions & 0 deletions sdk/identity/identity/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

### Features Added

- Added the `OnBehalfOfCredential`, which allows users to authenticate through the [On-Behalf-Of authentication flow](https://docs.microsoft.com/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow).

### Breaking Changes

#### Breaking Changes from 2.0.0-beta.5
Expand Down
1 change: 1 addition & 0 deletions sdk/identity/identity/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"./dist-esm/src/credentials/usernamePasswordCredential.js": "./dist-esm/src/credentials/usernamePasswordCredential.browser.js",
"./dist-esm/src/credentials/azurePowerShellCredential.js": "./dist-esm/src/credentials/azurePowerShellCredential.browser.js",
"./dist-esm/src/credentials/applicationCredential.js": "./dist-esm/src/credentials/applicationCredential.browser.js",
"./dist-esm/src/credentials/onBehalfOfCredential.js": "./dist-esm/src/credentials/onBehalfOfCredential.browser.js",
"./dist-esm/src/util/authHostEnv.js": "./dist-esm/src/util/authHostEnv.browser.js",
"./dist-esm/src/tokenCache/TokenCachePersistence.js": "./dist-esm/src/tokenCache/TokenCachePersistence.browser.js",
"./dist-esm/src/extensions/consumer.js": "./dist-esm/src/extensions/consumer.browser.js",
Expand Down
10 changes: 10 additions & 0 deletions sdk/identity/identity/review/identity.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,16 @@ export class ManagedIdentityCredential implements TokenCredential {
getToken(scopes: string | string[], options?: GetTokenOptions): Promise<AccessToken>;
}

// @public
export class OnBehalfOfCredential implements TokenCredential {
constructor(tenantId: string, clientId: string, clientSecret: string, userAssertionToken: string, options?: OnBehalfOfCredentialOptions);
getToken(scopes: string | string[], options?: GetTokenOptions): Promise<AccessToken>;
}

// @public
export interface OnBehalfOfCredentialOptions extends TokenCredentialOptions, CredentialPersistenceOptions {
}

// @public
export enum RegionalAuthority {
AsiaEast = "eastasia",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { TokenCredential, AccessToken } from "@azure/core-auth";

import { credentialLogger, formatError } from "../util/logging";

const BrowserNotSupportedError = new Error("OnBehalfOfCredential is not supported in the browser.");
const logger = credentialLogger("OnBehalfOfCredential");

export class OnBehalfOfCredential implements TokenCredential {
Copy link
Member

Choose a reason for hiding this comment

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

Aren't ClientSecret and ClientCert at least functional in browsers (even if that use case isn't supported?). Should OBO work in browsers as well if CSC does?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

constructor() {
logger.info(formatError("", BrowserNotSupportedError));
Copy link
Member

Choose a reason for hiding this comment

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

What's up with the empty string?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This empty string is just the scope. Generally, the use cases of this method pass a scope through, and the scope is far smaller than the message. Moving the scope after the message looks awkward for this specific internal utility, IMO

throw BrowserNotSupportedError;
}

public getToken(): Promise<AccessToken | null> {
logger.getToken.info(formatError("", BrowserNotSupportedError));
throw BrowserNotSupportedError;
}
}
71 changes: 71 additions & 0 deletions sdk/identity/identity/src/credentials/onBehalfOfCredential.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { AccessToken, GetTokenOptions, TokenCredential } from "@azure/core-auth";

import { MsalOnBehalfOf } from "../msal/nodeFlows/msalOnBehalfOf";
import { credentialLogger } from "../util/logging";
import { trace } from "../util/tracing";
import { MsalFlow } from "../msal/flows";
import { OnBehalfOfCredentialOptions } from "./onBehalfOfCredentialOptions";

const logger = credentialLogger("OnBehalfOfCredential");

/**
* Enables authentication to Azure Active Directory using the [On Behalf Of flow](https://docs.microsoft.com/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow).
*/
export class OnBehalfOfCredential implements TokenCredential {
private msalFlow: MsalFlow;

/**
* Creates an instance of the {@link OnBehalfOfCredential} with the details
* needed to authenticate against Azure Active Directory with a client
* secret, and an user assertion.
*
* Example using the `KeyClient` from [\@azure/keyvault-keys](https://www.npmjs.com/package/\@azure/keyvault-keys):
*
* ```ts
* const tokenCredential = new OnBehalfOfCredential(tenantId, clientId, clientSecret, "access-token");
* const client = new KeyClient("vault-url", tokenCredential);
*
* await client.getKey("key-name", { authenticationOptions: { userAssertion } });
sadasant marked this conversation as resolved.
Show resolved Hide resolved
* ```
*
* @param tenantId - The Azure Active Directory tenant (directory) ID.
* @param clientId - The client (application) ID of an App Registration in the tenant.
* @param clientSecret - A client secret that was generated for the App Registration.
* @param options - Options for configuring the client which makes the authentication request.
*/
constructor(
private tenantId: string,
private clientId: string,
private clientSecret: string,
sadasant marked this conversation as resolved.
Show resolved Hide resolved
private userAssertionToken: string,
private options: OnBehalfOfCredentialOptions = {}
) {
this.msalFlow = new MsalOnBehalfOf({
...this.options,
logger,
clientId: this.clientId,
tenantId: this.tenantId,
clientSecret: this.clientSecret,
userAssertionToken: this.userAssertionToken,
tokenCredentialOptions: this.options
});
}
sadasant marked this conversation as resolved.
Show resolved Hide resolved

/**
* Authenticates with Azure Active Directory and returns an access token if successful.
* If authentication fails, a {@link CredentialUnavailableError} will be thrown with the details of the failure.
*
* @param scopes - The list of scopes for which the token will have access.
* @param options - The options used to configure any requests this
* TokenCredential implementation might make.
*/
async getToken(scopes: string | string[], options: GetTokenOptions = {}): Promise<AccessToken> {
return trace(`${this.constructor.name}.getToken`, options, async (newOptions) => {
const arrayScopes = Array.isArray(scopes) ? scopes : [scopes];
return this.msalFlow.getToken(arrayScopes, newOptions);
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { TokenCredentialOptions } from "../client/identityClient";
import { CredentialPersistenceOptions } from "./credentialPersistenceOptions";

/**
* Optional parameters for the {@link OnBehalfOfCredential} class.
*/
export interface OnBehalfOfCredentialOptions
extends TokenCredentialOptions,
CredentialPersistenceOptions {}
3 changes: 3 additions & 0 deletions sdk/identity/identity/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ export {
VisualStudioCodeCredentialOptions
} from "./credentials/visualStudioCodeCredential";

export { OnBehalfOfCredential } from "./credentials/onBehalfOfCredential";
export { OnBehalfOfCredentialOptions } from "./credentials/onBehalfOfCredentialOptions";

export { TokenCachePersistenceOptions } from "./msal/nodeFlows/tokenCachePersistenceOptions";

export {
Expand Down
49 changes: 49 additions & 0 deletions sdk/identity/identity/src/msal/nodeFlows/msalOnBehalfOf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { AccessToken } from "@azure/core-auth";

import { CredentialFlowGetTokenOptions } from "../credentials";
import { MsalNodeOptions, MsalNode } from "./nodeCommon";

/**
* Options that can be passed to configure MSAL to handle On-Behalf-Of authentication requests.
* @internal
*/
export interface MSALOnBehalfOfOptions extends MsalNodeOptions {
clientSecret: string;
userAssertionToken: string;
}

/**
* MSAL on behalf of flow. Calls to MSAL's confidential application's `acquireTokenOnBehalfOf` during `doGetToken`.
* @internal
*/
export class MsalOnBehalfOf extends MsalNode {
private userAssertionToken: string;

constructor(options: MSALOnBehalfOfOptions) {
super(options);
this.logger.info("Initialized MSAL's On-Behalf-Of flow");
this.requiresConfidential = true;
this.msalConfig.auth.clientSecret = options.clientSecret;
this.userAssertionToken = options.userAssertionToken;
}

protected async doGetToken(
scopes: string[],
options: CredentialFlowGetTokenOptions = {}
): Promise<AccessToken> {
try {
const result = await this.confidentialApp!.acquireTokenOnBehalfOf({
scopes,
correlationId: options.correlationId,
authority: options.authority,
oboAssertion: this.userAssertionToken
});
return this.handleResult(scopes, this.clientId, result || undefined);
} catch (err) {
throw this.handleError(scopes, err, options);
}
}
}
40 changes: 23 additions & 17 deletions sdk/identity/identity/test/httpRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,8 @@ export async function prepareIdentityTests({
}

if (replaceLogger) {
AzureLogger.log = (args) => {
logMessages.push(args);
AzureLogger.log = (...args) => {
logMessages.push(args.join(" "));
};
}

Expand Down Expand Up @@ -174,23 +174,29 @@ export async function prepareIdentityTests({
const providerObject = provider === "http" ? http : https;
const totalOptions: http.RequestOptions[] = [];

sandbox.replace(
providerObject,
"request",
(options: string | URL | http.RequestOptions, resolve: any) => {
totalOptions.push(options as http.RequestOptions);
try {
sandbox.replace(
providerObject,
"request",
(options: string | URL | http.RequestOptions, resolve: any) => {
totalOptions.push(options as http.RequestOptions);

const { response, error } = responses.shift()!;
if (error) {
throw error;
} else {
resolve(responseToIncomingMessage(response!));
const { response, error } = responses.shift()!;
if (error) {
throw error;
} else {
resolve(responseToIncomingMessage(response!));
}
const request = createRequest();
spies.push(sandbox.spy(request, "end"));
return request;
}
const request = createRequest();
spies.push(sandbox.spy(request, "end"));
return request;
}
);
);
} catch (e) {
console.debug(
"Failed to replace the request. This might be expected if you're running multiple sendCredentialRequests() calls."
);
}

return totalOptions;
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { assert } from "chai";
import { isNode } from "@azure/core-util";
import { OnBehalfOfCredential } from "../../../src";
import {
createResponse,
IdentityTestContext,
SendCredentialRequests
} from "../../httpRequestsCommon";
import { prepareIdentityTests, prepareMSALResponses } from "../../httpRequests";

describe("OnBehalfOfCredential", function() {
let testContext: IdentityTestContext;
let sendCredentialRequests: SendCredentialRequests;

beforeEach(async function() {
testContext = await prepareIdentityTests({ replaceLogger: true, logLevel: "verbose" });
sendCredentialRequests = testContext.sendCredentialRequests;
});
afterEach(async function() {
if (isNode) {
delete process.env.AZURE_AUTHORITY_HOST;
}
await testContext.restore();
});

it("authenticates", async () => {
const credential = new OnBehalfOfCredential("adfs", "client", "secret", "user-assertion");

const newMSALClientLogs = () =>
testContext.logMessages.filter((message) =>
message.match("Initialized MSAL's On-Behalf-Of flow")
).length;

const authDetails = await sendCredentialRequests({
scopes: ["https://test/.default"],
credential,
secureResponses: [
...prepareMSALResponses(),
createResponse(200, {
access_token: "token",
expires_on: "06/20/2019 02:57:58 +00:00"
})
]
});

assert.isNumber(authDetails.result?.expiresOnTimestamp);

// Just checking that a new MSAL client was created.
// This kind of testing will be important as we improve the On-Behalf-Of Credential.
assert.equal(newMSALClientLogs(), 1);
});
});