[Identity] Simple OnBehalfOfCredential (#17137)

* WIP

* added test file and another small test-related fix

* fixed test after things were up to date

* accepting certificate paths

* feedback from Jeff

* small improvements

* moved sendCertificateChain to the OnBehalfOfCredentialCertificateConfiguration

* inheritance improvements, by Jeff

* small fixes
This commit is contained in:
Daniel Rodríguez 2021-09-08 18:39:08 -05:00 коммит произвёл GitHub
Родитель 01e60581d4
Коммит 35f3eddd60
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
13 изменённых файлов: 487 добавлений и 66 удалений

Просмотреть файл

@ -4,10 +4,13 @@
### 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).
- `ManagedIdentityCredential` now supports token exchange authentication.
### Breaking Changes
- `ClientCertificateCredential` now evaluates the validity of the PEM certificate path on `getToken` and not on the constructor.
#### Breaking Changes from 2.0.0-beta.5
- The property named `selectedCredential` that was added to `ChainedTokenCredential` and `DefaultAzureCredential` has been removed, since customers reported that logging was enough.

Просмотреть файл

@ -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",

Просмотреть файл

@ -248,6 +248,33 @@ export class ManagedIdentityCredential implements TokenCredential {
getToken(scopes: string | string[], options?: GetTokenOptions): Promise<AccessToken>;
}
// @public
export class OnBehalfOfCredential implements TokenCredential {
constructor(configuration: OnBehalfOfCredentialSecretConfiguration | OnBehalfOfCredentialCertificateConfiguration, options?: OnBehalfOfCredentialOptions);
getToken(scopes: string | string[], options?: GetTokenOptions): Promise<AccessToken>;
}
// @public
export interface OnBehalfOfCredentialCertificateConfiguration {
certificatePath: string;
clientId: string;
sendCertificateChain?: boolean;
tenantId: string;
userAssertionToken: string;
}
// @public
export interface OnBehalfOfCredentialOptions extends TokenCredentialOptions, CredentialPersistenceOptions {
}
// @public
export interface OnBehalfOfCredentialSecretConfiguration {
clientId: string;
clientSecret: string;
tenantId: string;
userAssertionToken: string;
}
// @public
export enum RegionalAuthority {
AsiaEast = "eastasia",

Просмотреть файл

@ -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 credentialName = "OnBehalfOfCredential";
const BrowserNotSupportedError = new Error(`${credentialName}: Not supported in the browser.`);
const logger = credentialLogger(credentialName);
export class OnBehalfOfCredential implements TokenCredential {
constructor() {
logger.info(formatError("", BrowserNotSupportedError));
throw BrowserNotSupportedError;
}
public getToken(): Promise<AccessToken | null> {
logger.getToken.info(formatError("", BrowserNotSupportedError));
throw BrowserNotSupportedError;
}
}

Просмотреть файл

@ -0,0 +1,132 @@
// 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 credentialName = "OnBehalfOfCredential";
const logger = credentialLogger(credentialName);
/**
* Defines the configuration parameters to authenticate the {@link OnBehalfOfCredential} with a secret.
*/
export interface OnBehalfOfCredentialSecretConfiguration {
/**
* The Azure Active Directory tenant (directory) ID.
*/
tenantId: string;
/**
* The client (application) ID of an App Registration in the tenant.
*/
clientId: string;
/**
* A client secret that was generated for the App Registration.
*/
clientSecret: string;
/**
* The user assertion for the On-Behalf-Of flow.
*/
userAssertionToken: string;
}
/**
* Defines the configuration parameters to authenticate the {@link OnBehalfOfCredential} with a certificate.
*/
export interface OnBehalfOfCredentialCertificateConfiguration {
/**
* The Azure Active Directory tenant (directory) ID.
*/
tenantId: string;
/**
* The client (application) ID of an App Registration in the tenant.
*/
clientId: string;
/**
* The path to a PEM-encoded public/private key certificate on the filesystem.
*/
certificatePath: string;
/**
* Option to include x5c header for SubjectName and Issuer name authorization.
* Set this option to send base64 encoded public certificate in the client assertion header as an x5c claim
*/
sendCertificateChain?: boolean;
/**
* The user assertion for the On-Behalf-Of flow.
*/
userAssertionToken: string;
}
/**
* 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 or a path to a PEM certificate, 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, // or `certificatePath: "/path/to/certificate.pem"
* userAssertionToken: "access-token"
* });
* const client = new KeyClient("vault-url", tokenCredential);
*
* await client.getKey("key-name");
* ```
*
* @param configuration - Configuration specific to this credential.
* @param options - Optional parameters, generally common across credentials.
*/
constructor(
private configuration:
| OnBehalfOfCredentialSecretConfiguration
| OnBehalfOfCredentialCertificateConfiguration,
private options: OnBehalfOfCredentialOptions = {}
) {
const { tenantId, clientId, userAssertionToken } = configuration;
const secretConfiguration = configuration as OnBehalfOfCredentialSecretConfiguration;
const certificateConfiguration = configuration as OnBehalfOfCredentialCertificateConfiguration;
if (
!tenantId ||
!clientId ||
!(secretConfiguration.clientSecret || certificateConfiguration.certificatePath) ||
!userAssertionToken
) {
throw new Error(
`${credentialName}: tenantId, clientId, clientSecret (or certificatePath) and userAssertionToken are required parameters.`
);
}
this.msalFlow = new MsalOnBehalfOf({
...this.options,
...this.configuration,
logger,
tokenCredentialOptions: this.options
});
}
/**
* 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 the underlying network requests.
*/
async getToken(scopes: string | string[], options: GetTokenOptions = {}): Promise<AccessToken> {
return trace(`${credentialName}.getToken`, options, async (newOptions) => {
const arrayScopes = Array.isArray(scopes) ? scopes : [scopes];
return this.msalFlow!.getToken(arrayScopes, newOptions);
});
}
}

Просмотреть файл

@ -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 {}

Просмотреть файл

@ -59,6 +59,13 @@ export {
VisualStudioCodeCredentialOptions
} from "./credentials/visualStudioCodeCredential";
export {
OnBehalfOfCredential,
OnBehalfOfCredentialSecretConfiguration,
OnBehalfOfCredentialCertificateConfiguration
} from "./credentials/onBehalfOfCredential";
export { OnBehalfOfCredentialOptions } from "./credentials/onBehalfOfCredentialOptions";
export { TokenCachePersistenceOptions } from "./msal/nodeFlows/tokenCachePersistenceOptions";
export {

Просмотреть файл

@ -1,22 +1,24 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import { readFileSync } from "fs";
import { readFile } from "fs";
import { createHash } from "crypto";
import { promisify } from "util";
import { AccessToken } from "@azure/core-auth";
import { MsalNodeOptions, MsalNode } from "./nodeCommon";
import { formatError } from "../../util/logging";
import { CredentialFlowGetTokenOptions } from "../credentials";
const readFileAsync = promisify(readFile);
/**
* Options that can be passed to configure MSAL to handle client certificates.
* @internal
*/
export interface MSALClientCertificateOptions extends MsalNodeOptions {
/**
* Location of the certificate.
* Location of the PEM certificate.
*/
certificatePath: string;
/**
@ -45,60 +47,78 @@ interface CertificateParts {
x5c: string;
}
/**
* Tries to asynchronously load a certificate from the given path.
*
* @param certificatePath - Path to the certificate.
* @param sendCertificateChain - Option to include x5c header for SubjectName and Issuer name authorization.
* @returns - The certificate parts, or `undefined` if the certificate could not be loaded.
* @internal
*/
export async function parseCertificate(
certificatePath: string,
sendCertificateChain?: boolean
): Promise<CertificateParts> {
const certificateParts: Partial<CertificateParts> = {};
certificateParts.certificateContents = await readFileAsync(certificatePath, "utf8");
if (sendCertificateChain) {
certificateParts.x5c = certificateParts.certificateContents;
}
const certificatePattern = /(-+BEGIN CERTIFICATE-+)(\n\r?|\r\n?)([A-Za-z0-9+/\n\r]+=*)(\n\r?|\r\n?)(-+END CERTIFICATE-+)/g;
const publicKeys: string[] = [];
// Match all possible certificates, in the order they are in the file. These will form the chain that is used for x5c
let match;
do {
match = certificatePattern.exec(certificateParts.certificateContents);
if (match) {
publicKeys.push(match[3]);
}
} while (match);
if (publicKeys.length === 0) {
throw new Error("The file at the specified path does not contain a PEM-encoded certificate.");
}
certificateParts.thumbprint = createHash("sha1")
.update(Buffer.from(publicKeys[0], "base64"))
.digest("hex")
.toUpperCase();
return certificateParts as CertificateParts;
}
/**
* MSAL client certificate client. Calls to MSAL's confidential application's `acquireTokenByClientCredential` during `doGetToken`.
* @internal
*/
export class MsalClientCertificate extends MsalNode {
private certificatePath: string;
private sendCertificateChain?: boolean;
constructor(options: MSALClientCertificateOptions) {
super(options);
this.requiresConfidential = true;
this.certificatePath = options.certificatePath;
this.sendCertificateChain = options.sendCertificateChain;
const parts = this.parseCertificate(options.certificatePath);
this.msalConfig.auth.clientCertificate = {
thumbprint: parts.thumbprint,
privateKey: parts.certificateContents,
x5c: parts.x5c
};
}
private parseCertificate(certificatePath: string): CertificateParts {
const certificateParts: Partial<CertificateParts> = {};
certificateParts.certificateContents = readFileSync(certificatePath, "utf8");
if (this.sendCertificateChain) {
certificateParts.x5c = certificateParts.certificateContents;
}
const certificatePattern = /(-+BEGIN CERTIFICATE-+)(\n\r?|\r\n?)([A-Za-z0-9+/\n\r]+=*)(\n\r?|\r\n?)(-+END CERTIFICATE-+)/g;
const publicKeys: string[] = [];
// Match all possible certificates, in the order they are in the file. These will form the chain that is used for x5c
let match;
do {
match = certificatePattern.exec(certificateParts.certificateContents);
if (match) {
publicKeys.push(match[3]);
}
} while (match);
if (publicKeys.length === 0) {
const error = new Error(
"The file at the specified path does not contain a PEM-encoded certificate."
);
// Changing the MSAL configuration asynchronously
async init(options?: CredentialFlowGetTokenOptions): Promise<void> {
try {
const parts = await parseCertificate(this.certificatePath, this.sendCertificateChain);
this.msalConfig.auth.clientCertificate = {
thumbprint: parts.thumbprint,
privateKey: parts.certificateContents,
x5c: parts.x5c
};
} catch (error) {
this.logger.info(formatError("", error));
throw error;
}
certificateParts.thumbprint = createHash("sha1")
.update(Buffer.from(publicKeys[0], "base64"))
.digest("hex")
.toUpperCase();
return certificateParts as CertificateParts;
return super.init(options);
}
protected async doGetToken(

Просмотреть файл

@ -11,6 +11,9 @@ import { MsalNodeOptions, MsalNode } from "./nodeCommon";
* @internal
*/
export interface MSALClientSecretOptions extends MsalNodeOptions {
/**
* A client secret that was generated for the App Registration.
*/
clientSecret: string;
}

Просмотреть файл

@ -0,0 +1,90 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import { AccessToken } from "@azure/core-auth";
import { formatError } from "../../util/logging";
import { CredentialFlowGetTokenOptions } from "../credentials";
import { parseCertificate } from "./msalClientCertificate";
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 {
/**
* A client secret that was generated for the App Registration.
*/
clientSecret?: string;
/**
* Location of the PEM certificate.
*/
certificatePath?: string;
/**
* Option to include x5c header for SubjectName and Issuer name authorization.
* Set this option to send base64 encoded public certificate in the client assertion header as an x5c claim
*/
sendCertificateChain?: boolean;
/**
* The user assertion for the On-Behalf-Of flow.
*/
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;
private certificatePath?: string;
private sendCertificateChain?: boolean;
private clientSecret?: string;
constructor(options: MSALOnBehalfOfOptions) {
super(options);
this.logger.info("Initialized MSAL's On-Behalf-Of flow");
this.requiresConfidential = true;
this.userAssertionToken = options.userAssertionToken;
this.certificatePath = options.certificatePath;
this.sendCertificateChain = options.sendCertificateChain;
this.clientSecret = options.clientSecret;
}
// Changing the MSAL configuration asynchronously
async init(options?: CredentialFlowGetTokenOptions): Promise<void> {
if (this.certificatePath) {
try {
const parts = await parseCertificate(this.certificatePath, this.sendCertificateChain);
this.msalConfig.auth.clientCertificate = {
thumbprint: parts.thumbprint,
privateKey: parts.certificateContents,
x5c: parts.x5c
};
} catch (error) {
this.logger.info(formatError("", error));
throw error;
}
} else {
this.msalConfig.auth.clientSecret = this.clientSecret;
}
return super.init(options);
}
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);
}
}
}

Просмотреть файл

@ -114,8 +114,8 @@ export async function prepareIdentityTests({
}
if (replaceLogger) {
AzureLogger.log = (args) => {
logMessages.push(args);
AzureLogger.log = (...args) => {
logMessages.push(args.join(" "));
};
}
@ -171,23 +171,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;
};

Просмотреть файл

@ -79,14 +79,19 @@ describe("ClientCertificateCredential (internal)", function() {
});
});
it("throws when given a file that doesn't contain a PEM-formatted certificate", () => {
assert.throws(() => {
new ClientCertificateCredential(
"tenant",
"client",
path.resolve(__dirname, "../src/index.ts")
);
});
it("throws when given a file that doesn't contain a PEM-formatted certificate", async function(this: Context) {
const fullPath = path.resolve(__dirname, "../src/index.ts");
const credential = new ClientCertificateCredential("tenant", "client", fullPath);
let error: Error | undefined;
try {
await credential.getToken(scope);
} catch (_error) {
error = _error;
}
assert.ok(error);
assert.deepEqual(error?.message, `ENOENT: no such file or directory, open '${fullPath}'`);
});
it("Authenticates silently after the initial request", async function(this: Context) {

Просмотреть файл

@ -0,0 +1,94 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import * as path from "path";
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 with a secret", async () => {
const credential = new OnBehalfOfCredential({
tenantId: "adfs",
clientId: "client",
clientSecret: "secret",
userAssertionToken: "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);
});
it("authenticates with a certificate path", async () => {
const certificatePath = path.join("assets", "fake-cert.pem");
const credential = new OnBehalfOfCredential({
tenantId: "adfs",
clientId: "client",
certificatePath,
userAssertionToken: "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);
});
});