Skip to content

Commit

Permalink
Added Node.js proxy provider sample (#739)
Browse files Browse the repository at this point in the history
* Added Node.js proxy provider sample

* Update index.html

Made changes suggested by review

Co-authored-by: Nikola Metulev <[email protected]>
  • Loading branch information
jasonjoh and nmetulev authored Nov 6, 2020
1 parent 41e5cad commit 21489e1
Show file tree
Hide file tree
Showing 12 changed files with 619 additions and 0 deletions.
6 changes: 6 additions & 0 deletions samples/proxy-provider-node/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules/
build/

yarn-error.log
.env
config.js
164 changes: 164 additions & 0 deletions samples/proxy-provider-node/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Node.js Graph Proxy sample

This sample implements a Node.js server that acts as a proxy for Microsoft Graph. This can be used along with the Microsoft Graph Toolkit's [proxy provider](https://docs.microsoft.com/graph/toolkit/providers/proxy) to make all Microsoft Graph API calls from a backend service.

## Authorization modes

The proxy service supports to authorization modes: pass-through and on-behalf-of.

> **NOTE**
> The [sample client application](./client) requires using the on-behalf-of mode.
### Pass-through

In pass-through mode, the service takes whatever bearer token is sent in the `Authorization` header from the client and tries to use that to call Microsoft Graph. This mode is primarily for ease of testing.

### On-behalf-of

In on-behalf-of mode, the service uses the Microsoft identity platform's [on-behalf-of flow](https://docs.microsoft.com/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow) to exchange the token sent by the calling client for a Microsoft Graph token. The next section gives instructions on how to register the app in Azure Active Directory.

#### App registration

This sample requires two app registrations. This is needed to take advantage of [combined consent](https://docs.microsoft.com/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow#default-and-combined-consent) with the on-behalf-of flow in the Microsoft identity platform. Create an app registration for:

- The Graph Proxy service
- The React front-end app

##### Graph Proxy service

1. Open a browser and navigate to the [Azure Active Directory admin center](https://aad.portal.azure.com). Login using a **personal account** (aka: Microsoft Account) or **Work or School Account**.

1. Select **Azure Active Directory** in the left-hand navigation, then select **App registrations** under **Manage**.

1. Select **New registration**. On the **Register an application** page, set the values as follows.

- Set **Name** to `Node.js Graph Proxy`.
- Set **Supported account types** to **Accounts in any organizational directory and personal Microsoft accounts**.
- Under **Redirect URI**, leave the value empty.

1. Select **Register**. On the **Node.js Graph Proxy** page, copy the value of the **Application (client) ID** and **Directory (tenant) ID**. Save them, you will need them in the next step.

1. Select **Certificates & secrets** under **Manage**. Select the **New client secret** button. Enter a value in **Description** and select one of the options for **Expires** and select **Add**.

1. Copy the client secret value before you leave this page. You will need it in the next step.

> **IMPORTANT**
> This client secret is never shown again, so make sure you copy it now.
1. Select **API permissions** under **Manage**, then select **Add a permission**.

1. Select **Microsoft Graph**, then **Delegated permissions**.

1. Select the following permissions, then select **Add permissions**.

- **Presence.Read** - this will allow the app to read the authenticated user's presence.
- **Tasks.ReadWrite** - this allows the app to read and write the user's To-Do tasks.
- **User.Read** - this will allow the app to read the user's profile and photo.

> **NOTE**
> These are the permissions required for the Microsoft Graph Toolkit components used in the client application. If you use different components, you may require additional permissions. See the [documentation](https://docs.microsoft.com/graph/toolkit/overview) for each component for details on required permissions.
1. Select **Expose an API**. Select the **Set** link next to **Application ID URI**. Accept the default and select **Save**.

1. In the **Scopes defined by this API** section, select **Add a scope**. Fill in the fields as follows and select **Add scope**.

- **Scope name:** `access_as_user`
- **Who can consent?: Admins and users**
- **Admin consent display name:** `Access Graph Proxy as the user`
- **Admin consent description:** `Allows the app to call Microsoft Graph through a proxy service on users' behalf.`
- **User consent display name:** `Access Graph Proxy as you`
- **User consent description:** `Allows the app to call Microsoft Graph through a proxy service as you.`
- **State: Enabled**

1. Copy the new scope. You'll need this later.

##### React front-end app

1. Return to **App registration** in the Azure portal, then select **New registration**.

1. On the **Register an application** page, set the values as follows.

- Set **Name** to `Node.js Graph Proxy Client`.
- Set **Supported account types** to **Accounts in any organizational directory and personal Microsoft accounts**.
- Under **Redirect URI**, set the first drop-down to `Single-page application (SPA)` and set the value to `http://localhost:8000/authcomplete`.

1. Select **Register**. On the **Node.js Graph Client** page, copy the value of the **Application (client) ID** and save it, you will need it in the next step.

1. Select **API permissions** under **Manage**, then select **Add a permission**.

1. Select **APIs my organization uses**, then search for `Node.js Graph Proxy`. Select **Node.js Graph Proxy** in the list.

1. Select the **access_as_user** permission, then select **Add permissions**.

1. In the **Configured permissions** list, remove the **User.Read** permission under **Microsoft Graph**.

##### Add client to proxy's known applications

1. Return to the **Node.js Graph Proxy** app registration in the Azure portal, then select **Manifest** under **Manage**.

1. Locate the `"knownClientApplications": [],` line and replace it with the following, where `CLIENT_APP_ID` is the application ID of the **Node.js Graph Proxy Client** registration.

```json
"knownClientApplications": ["CLIENT_APP_ID"],
```

1. Select **Save**.

## Configuring the sample

1. Rename the example.env file to .env, and set the values as follows.

| Setting | Value |
|---------|-------|
| GRAPH_HOST | Set to the specific [Graph endpoint](https://docs.microsoft.com/graph/deployments#microsoft-graph-and-graph-explorer-service-root-endpoints) for your organization. |
| AUTH_PASS_THROUGH | Set to `true` to enable pass-through mode, `false` for on-behalf-of mode. |
| PROXY_APP_ID | The application ID of your **Node.js Graph Proxy** app registration |
| PROXY_APP_TENANT_ID | The tenant ID from your **Node.js Graph Proxy** app registration |
| PROXY_APP_SECRET | The client secret from your **Node.js Graph Proxy** app registration |

1. Rename the ./client/config.example.js to config.js and set the values as follows.

- Replace `YOUR_PROXY_CLIENT_APP_ID` with the application ID of your **Node.js Graph Proxy Client** app registration.
- Replace `YOUR_PROXY_APP_ID` with the application ID of your **Node.js Graph Proxy** app registration.

## Run the sample

1. Open your command-line interface (CLI) in the root of this project and run the following command to install dependencies.

```Shell
yarn install
```

> **NOTE**
> This only needs to be done once.

1. Run the following command to start the sample.

```Shell
yarn start
```

1. Open your browser and go to `http://localhost:8000`.

1. Select the **Sign In** button to sign in. After signing in and granting consent, the app should load data into the components.

### How do I know it worked?

If everything worked correctly, the components should load data just as they would normally without using the proxy provider. You can verify that requests are going through the proxy (rather than directly to Graph) by reviewing the console output in your CLI where you ran `yarn start`.

```Shell
⚡️[server]: Server is running at http://localhost:8000
Auth mode: on-behalf-of
GET /v1.0/me
Accept: */*
POST /v1.0/$batch
Accept: */*
Content-Type: application/json
GET /v1.0/me
Accept: */*
POST /v1.0/$batch
Accept: */*
Content-Type: application/json
GET /beta/me/outlook/taskGroups
Accept: */*
```
90 changes: 90 additions & 0 deletions samples/proxy-provider-node/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import jwt, { SigningKeyCallback, JwtHeader } from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
import * as msal from '@azure/msal-node';

const keyClient = jwksClient({
jwksUri: 'https://login.microsoftonline.com/common/discovery/keys'
});

/**
* Parses the JWT header and retrieves the appropriate public key
* @param {JwtHeader} header - The JWT header
* @param {SigningKeyCallback} callback - Callback function
*/
function getSigningKey(header: JwtHeader, callback: SigningKeyCallback): void {
if (header) {
keyClient.getSigningKey(header.kid!, (err, key) => {
if (err) {
callback(err, undefined);
} else {
callback(null, key.getPublicKey());
}
});
}
}

/**
* Validates a JWT
* @param {string} authHeader - The Authorization header value containing a JWT bearer token
* @returns {Promise<string | null>} - Returns the token if valid, returns null if invalid
*/
async function validateJwt(authHeader: string): Promise<string | null> {
return new Promise((resolve, reject) => {
const token = authHeader.split(' ')[1];

const validationOptions = {
audience: process.env.PROXY_APP_ID,
issuer: `https://login.microsoftonline.com/${process.env.PROXY_APP_TENANT_ID}/v2.0`
};

jwt.verify(token, getSigningKey, validationOptions, (err, payload) => {
if (err) {
resolve(null);
}

resolve(token);
});
});
}

/**
* Gets an access token for the user using the on-behalf-of flow
* @param authHeader - The Authorization header value containing a JWT bearer token
* @returns {Promise<string | null>} - Returns the access token if successful, null if not
*/
export default async function getAccessTokenOnBehalfOf(authHeader: string): Promise<string | null> {
// Validate the token
const token = await validateJwt(authHeader);

if (token) {
// Create an MSAL client
const msalClient = new msal.ConfidentialClientApplication({
auth: {
clientId: process.env.PROXY_APP_ID!,
clientSecret: process.env.PROXY_APP_SECRET
}
});

try {
// Make the on-behalf-of request
// This exchanges the incoming token (which is scoped for the proxy service)
// for a new token that is scoped for Microsoft Graph
const result = await msalClient.acquireTokenOnBehalfOf({
oboAssertion: token,
skipCache: true,
scopes: ['https://graph.microsoft.com/.default']
});

return result.accessToken;
} catch (error) {
console.log(`Token error: ${error}`);
return null;
}

} else {
return null;
}
}
11 changes: 11 additions & 0 deletions samples/proxy-provider-node/client/config.example.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

const msalConfig = {
auth: {
clientId: 'YOUR_PROXY_CLIENT_APP_ID',
redirectUri: 'http://localhost:8000/authcomplete'
}
}

const apiScopes = ['api://YOUR_PROXY_APP_ID/.default']
Binary file added samples/proxy-provider-node/client/g-raph.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
99 changes: 99 additions & 0 deletions samples/proxy-provider-node/client/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<!-- Copyright (c) Microsoft Corporation.
Licensed under the MIT License. -->

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>Node.js Graph Proxy Client</title>

<link rel="shortcut icon" href="g-raph.png">
<link href="style.css" rel="stylesheet" type="text/css" />
</head>

<body>
<main id="main-container" role="main" class="container">
<h2>Login component</h2>
<mgt-login></mgt-login>
<h2>Person component</h2>
<mgt-person person-query="me" person-card="hover" show-presence="true" view="twolines"></mgt-person>
<h2>Tasks component</h2>
<mgt-tasks data-source="todo"></mgt-tasks>
</main>

<!-- MSAL -->
<script src="https://alcdn.msauth.net/browser/2.5.2/js/msal-browser.min.js"></script>

<!-- Graph Toolkit -->
<script src="https://unpkg.com/@microsoft/mgt/dist/bundle/mgt-loader.js"></script>

<!-- Configuration -->
<script src="config.js"></script>

<script>
// This is used to sign in the user and get a token that
// allows the client to call the proxy
const msalClient = new msal.PublicClientApplication(msalConfig);

const provider = new mgt.ProxyProvider('http://localhost:8000/apiproxy', async() => {
// This code executes for each call to the proxy to
// get any headers that it should add to the request.

// Get the user account name
const account = sessionStorage.getItem('msalAccount');
if (!account) {
throw new Error(
'User account missing from session. Please sign out and sign in again.');
}

// Build a silent token request - this takes advantage of
// token caching
const silentTokenRequest = {
// Permission scope is for the proxy, NOT Graph
scopes: apiScopes,
account: msalClient.getAccountByUsername(account)
}

try {
// Get the token and return it as an Authorization header
const result = await msalClient.acquireTokenSilent(silentTokenRequest);
return { Authorization: `Bearer ${result.accessToken}` };
} catch (silentError) {
// If silent requests fails with InteractionRequiredAuthError,
// attempt to get the token interactively
if (silentError instanceof msal.InteractionRequiredAuthError) {
const interactiveResult = await msalClient.acquireTokenPopup({
scopes: apiScopes,
prompt: 'consent'
});
return { Authorization: `Bearer ${interactiveResult.accessToken}` };
} else {
throw silentError;
}
}
});

provider.login = async () => {
// Use MSAL to login
const authResult = await msalClient.loginPopup({
scopes: apiScopes
});

console.log(`Access token: ${authResult.accessToken}`);

sessionStorage.setItem('msalAccount', authResult.account.username);

provider.setState(mgt.ProviderState.SignedIn);
};

provider.logout = () => {
msalClient.logout();
sessionStorage.removeItem('msalAccount');
provider.setState(mgt.ProviderState.SignedOut);
};

mgt.Providers.globalProvider = provider;
</script>
</body>
</html>
3 changes: 3 additions & 0 deletions samples/proxy-provider-node/client/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
body {
font-family: "Segoe UI", "Segoe UI Web (West European)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif;
}
5 changes: 5 additions & 0 deletions samples/proxy-provider-node/example.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
GRAPH_HOST='https://graph.microsoft.com'
AUTH_PASS_THROUGH=false
PROXY_APP_ID='YOUR_PROXY_APP_ID'
PROXY_APP_TENANT_ID='YOUR_TENANT_ID'
PROXY_APP_SECRET='YOUR_PROXY_APP_SECRET'
Loading

0 comments on commit 21489e1

Please sign in to comment.