Skip to content

Commit

Permalink
Added E2E Test: Client Credentials with Certificate from Key Vault (#…
Browse files Browse the repository at this point in the history
…7367)

This PR adds a sample with a corresponding e2e test.

A follow up PR that converts the sample to TypeScript is located
[here](#7368).

Additionally, some potential follow up work would be to re-use some of
the certificate transforming functionality introduced here and change
the way developers pass in certificates.

---------

Co-authored-by: Hector Morales <hemoral@microsoft.com>
  • Loading branch information
Robbie-Microsoft and hectormmg authored Nov 4, 2024
1 parent 098a957 commit dbc0040
Show file tree
Hide file tree
Showing 16 changed files with 492 additions and 27 deletions.
68 changes: 51 additions & 17 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion samples/e2eTestUtils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"private": true,
"main": "src/index.ts",
"dependencies": {
"@azure/identity": "^4.3.0",
"@azure/identity": "^4.5.0",
"axios": "^1.7.4",
"dotenv": "^8.2.0",
"find-process": "^1.4.4"
Expand Down
91 changes: 91 additions & 0 deletions samples/e2eTestUtils/src/CertificateUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/

const { X509Certificate, createPrivateKey } = require("crypto");
const path = require("path");
const fs = require("fs");
const { execSync } = require("child_process");

import { getKeyVaultSecret } from "./KeyVaultUtils";

// define paths for temporary files
const p12FilePath = path.join(__dirname, "certificate.p12");
const certificateKEY = path.join(__dirname, "certificate.key");
const certificateCER = path.join(__dirname, "certificate.cer");

export const getCertificateInfo = async (
client: any,
secretName: string
): Promise<Array<string>> => {
const PKCS12CertificateBase64: string = await getKeyVaultSecret(
client,
secretName
);

// get the private key and the public certificate, in PKCS 12 format
const pkcs12Certificate = Buffer.from(PKCS12CertificateBase64, "base64");

// write the PKCS#12 certificate to a temporary file
fs.writeFileSync(p12FilePath, pkcs12Certificate);

try {
// get the private key from the pkcs12 file through openssl, via a synchronous child process
execSync(
`openssl pkcs12 -in ${p12FilePath} -nocerts -nodes -passin pass: | sed -ne '/-BEGIN PRIVATE KEY-/,/-END PRIVATE KEY-/p' > ${certificateKEY}`
);
const privateKey: string = fs.readFileSync(certificateKEY, "utf-8");
// this will be used to check if the private key matches the x5c, which ensures the x5c is in the correct order
const privateKeyObject = createPrivateKey(privateKey);

// get the x5c from the pkcs12 file through openssl, via a synchronous child process
execSync(
`openssl pkcs12 -in ${p12FilePath} -nokeys -nodes -passin pass: | sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p' > ${certificateCER}`
);
let x5c: string = fs.readFileSync(certificateCER, "utf-8");

// get a string list of the certificates from the x5c, where the strings will include -----BEGIN CERTIFICATE----- and -----END CERTIFICATE-----
const certificates = x5c.split(/(?=-----BEGIN CERTIFICATE-----\n)/g);

const x509FromFirstCertificate = new X509Certificate(certificates[0]);

// check if the private key matches the first certificate in the x5c
let thumbprint: string = "";
if (!x509FromFirstCertificate.checkPrivateKey(privateKeyObject)) {
const x509FromLastCertificate = new X509Certificate(
certificates[certificates.length - 1]
);

// if it doesn't match, the x5c may be reversed (this is common when exporting certificates from azure key vault)
// check if the private key matches the last certificate in the x5c
if (x509FromLastCertificate.checkPrivateKey(privateKeyObject)) {
// if it does, reverse the certs in the x5c
x5c = certificates.reverse().join("");
// format the thumbprint // A:B:C -> ABC
thumbprint = x509FromLastCertificate.fingerprint256.replaceAll(
":",
""
);
} else {
// if it doesn't match, the certificate is malformed
throw "Certificate is malformed";
}
} else {
// format the thumbprint // A:B:C -> ABC
thumbprint = x509FromFirstCertificate.fingerprint256.replaceAll(
":",
""
);
}

return [thumbprint, privateKey, x5c];
} catch (error) {
throw `Error processing PKCS#12 file: ${error}`;
} finally {
// clean up temporary files
fs.unlinkSync(p12FilePath);
fs.unlinkSync(certificateKEY);
fs.unlinkSync(certificateCER);
}
};
2 changes: 2 additions & 0 deletions samples/e2eTestUtils/src/Constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export const ENV_VARIABLES = {

export const LAB_API_ENDPOINT = "https://msidlab.com/api";
export const LAB_SCOPE = "https://request.msidlab.com/.default";
export const LAB_KEY_VAULT_URL = "https://msidlabs.vault.azure.net";
export const LAB_CERT_NAME = "LabAuth";

export const B2C_MSA_TEST_UPN = "b2cmsatest@outlook.com";

Expand Down
32 changes: 23 additions & 9 deletions samples/e2eTestUtils/src/KeyVaultUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,40 @@
const { DefaultAzureCredential } = require("@azure/identity");
const { SecretClient } = require("@azure/keyvault-secrets");

export const getKeyVaultSecretClient = (): Promise<void> => {
export const getKeyVaultSecretClient = (
keyVaultUrl?: string
): Promise<void> => {
return new Promise<void>(async (resolve, reject) => {
// DefaultAzureCredential expects the following three environment variables:
// DefaultAzureCredential expects the following four environment variables:
// * AZURE_TENANT_ID: The tenant ID in Azure Active Directory
// * AZURE_CLIENT_ID: The application (client) ID registered in the AAD tenant
// * AZURE_CLIENT_SECRET: The client secret for the registered application
// * AZURE_CLIENT_CERTIFICATE_PATH: The path of the LabAuth cert that was created in 1P's e2e-tests.yml
// * AZURE_CLIENT_SEND_CERTIFICATE_CHAIN: set to "true" - indicates that the LabAuth's x5c header should be sent to the keyvault
const keyVaultCredentials = new DefaultAzureCredential();

try {
const client = await new SecretClient(
process.env["KEY_VAULT_URL"],
keyVaultUrl || process.env["KEY_VAULT_URL"],
keyVaultCredentials
);
resolve(client);
return resolve(client);
} catch (error) {
reject(error);
return reject(error);
}
});
};

export const getKeyVaultSecret = async (
client: any,
secretName: string
): Promise<string> => {
try {
return (await client.getSecret(secretName)).value;
} catch (error) {
throw error;
}
};

export const getCredentials = (client: any): Promise<Array<string>> => {
const usernameSecret: string = "username";
const passwordSecret: string = "password";
Expand All @@ -35,16 +49,16 @@ export const getCredentials = (client: any): Promise<Array<string>> => {
try {
username = await client.getSecret(usernameSecret);
} catch (error) {
reject(error);
return reject(error);
}

let password: any;
try {
password = await client.getSecret(passwordSecret);
} catch (error) {
reject(error);
return reject(error);
}

resolve([username.value, password.value]);
return resolve([username.value, password.value]);
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"shouldPublish": false
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package-lock=false
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# MSAL Node Standalone Sample: Client Credentials Grant with Certificate

This sample demonstrates how to implement an MSAL Node [confidential client application](../../../lib/msal-node/docs/initialize-confidential-client-application.md) to acquire an access token with application permissions using the [OAuth 2.0 Client Credentials Grant](https://docs.microsoft.com/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow), via a certificate retrieved from a key vault.

The **Client Credentials** flow is most commonly used for a daemon or a command-line app that calls web APIs and does not have any user interaction.

MSAL Node also supports specifying a **regional authority** for acquiring tokens when using the client credentials flow. For more information on this, please refer to: [Regional Authorities](../../../lib/msal-node/docs/regional-authorities.md).

## Setup

Locate the folder where `package.json` resides in your terminal. Then type:

```console
npm install
```

## Register

1. Navigate to the [Microsoft Entra admin center](https://entra.microsoft.com) and select the **Microsoft Entra ID** service.
1. Select the **App Registrations** blade on the left, then select **New registration**.
1. In the **Register an application page** that appears, enter your application's registration information:
- In the **Name** section, enter a meaningful application name that will be displayed to users of the app, for example `msal-node-console`.
- Under **Supported account types**, select **Accounts in this organizational directory only**.
1. Select **Register** to create the application.
1. In the app's registration screen, find and note the **Application (client) ID** and **Directory (Tenant) ID**. You use these values in your app's configuration file(s) later.
1. In the app's registration screen, select the **Certificates & secrets** blade in the left.
- In the **Client secrets** section, select **New client secret**.
- Type a key description (for instance `app secret`),
- Select one of the available key durations (6 months, 12 months or Custom) as per your security posture.
- The generated key value will be displayed when you select the **Add** button. Copy and save the generated value for use in later steps.
1. In the app's registration screen, select the API permissions blade in the left to open the page where we add access to the APIs that your application needs.
- Select the **Add a permission** button and then,
- Ensure that the **Microsoft APIs** tab is selected.
- In the **Commonly used Microsoft APIs** section, select **Microsoft Graph**
- In the **Application permissions** section, select the **User.Read.All** in the list. Use the search box if necessary.
- Select the **Add permissions** button at the bottom.
- Finally, grant **admin consent** for this scope.

Before running the sample, you will need to replace the values in retrieve-cert-from-key-vault code as well as the configuration object:

```javascript
const keyVaultSecretClient = await getKeyVaultSecretClient(
"ENTER_KEY_VAULT_URL"
);
[thumbprint, privateKey, x5c] = await getCertificateInfo(
keyVaultSecretClient,
"ENTER_CERT_NAME"
);

const config = {
auth: {
clientId: "ENTER_CLIENT_ID",
authority: "https://login.microsoftonline.com/ENTER_TENANT_INFO",
clientCertificate: {
thumbprintSha256: thumbprint,
privateKey: privateKey,
x5c: x5c,
},
},
};
```

## Run the app

In the same folder, type:

```console
npm start
```

After that, you should see the response from Microsoft Entra ID in your terminal.

## More information

- [Tutorial: Call the Microsoft Graph API in a Node.js console app](https://docs.microsoft.com/azure/active-directory/develop/tutorial-v2-nodejs-console)
Loading

0 comments on commit dbc0040

Please sign in to comment.