Microsoft Graph Broker Auth Spike

This document records the Windows broker/WAM spike used to prove that Chamber can import a user profile from Microsoft Graph without directly reading or reusing another app's token cache.

Outcome: brokered auth worked.

Device-code auth with the A365/VS Code client ID was blocked with You don't have access to this, but MSAL Node with NativeBrokerPlugin successfully acquired a Graph User.Read token through the Windows broker and called /v1.0/me.

Windows WAM MSAL Node NativeBrokerPlugin Microsoft Graph /me

Why this matters

Chamber wants a Settings profile avatar flow similar to Teams: click an avatar, use the user's existing work account context where possible, then import name, role, location, and photo from Graph.

It is not safe or appropriate to scrape tokens from Windows Credential Manager, Teams, Office, VS Code, or GitHub Copilot. Those tokens are scoped to their owning app and resource. Instead, Chamber should ask the Windows broker for its own Graph token. The broker can satisfy that request from the signed-in Windows work account/session when policy allows.

Tested approaches

Approach Result Takeaway
MSAL device-code flow with A365/VS Code client ID Blocked: Microsoft returned You don't have access to this. Do not use device-code as the production path for this feature.
MSAL Node + NativeBrokerPlugin with A365/VS Code client ID Succeeded: broker discovered Windows accounts, prompt=none returned a Graph token, and /me returned profile fields. Use broker/WAM first for the Graph profile import path.

Dependencies

Install the MSAL packages used by the spike:

npm install @azure/msal-node@5.2.0 @azure/msal-node-extensions@5.2.0

Relevant package roles:

Working broker spike

This sample uses the A365/VS Code public client ID and Microsoft tenant ID, then asks the native broker for https://graph.microsoft.com/User.Read. It first tries an existing broker account silently, then tries prompt=none, then falls back to broker interactive UI.

import { createRequire } from 'node:module';

const require = createRequire('C:/src/chamber/package.json');
const { PublicClientApplication, PromptValue } = require('@azure/msal-node');
const { NativeBrokerPlugin } = require('@azure/msal-node-extensions');

const clientId = 'aebc6443-996d-45c2-90f0-388ff96faa56';
const tenantId = '72f988bf-86f1-41af-91ab-2d7cd011db47';
const scopes = ['https://graph.microsoft.com/User.Read'];

const app = new PublicClientApplication({
  auth: {
    clientId,
    authority: `https://login.microsoftonline.com/${tenantId}`,
  },
  broker: {
    nativeBrokerPlugin: new NativeBrokerPlugin(),
  },
});

console.log('Broker spike: discovering broker accounts...');
const accounts = await app.getAllAccounts();
console.log(`Broker spike: discovered ${accounts.length} account(s).`);

async function acquireToken() {
  if (accounts.length > 0) {
    try {
      console.log(`Trying silent token for ${accounts[0].username ?? '(unknown account)'}.`);
      return await app.acquireTokenSilent({
        account: accounts[0],
        scopes,
      });
    } catch (error) {
      console.log(`Silent failed: ${error instanceof Error ? error.message : String(error)}`);
    }
  }

  try {
    console.log('Trying prompt=none broker token.');
    return await app.acquireTokenInteractive({
      scopes,
      prompt: PromptValue.NONE,
    });
  } catch (error) {
    console.log(`prompt=none failed: ${error instanceof Error ? error.message : String(error)}`);
  }

  console.log('Trying broker interactive token.');
  return app.acquireTokenInteractive({ scopes });
}

const result = await acquireToken();
if (!result?.accessToken) {
  throw new Error('Broker MSAL returned no access token');
}

const response = await fetch('https://graph.microsoft.com/v1.0/me', {
  headers: {
    Authorization: `Bearer ${result.accessToken}`,
    Accept: 'application/json',
  },
});

const body = await response.text();
if (!response.ok) {
  throw new Error(`/me failed ${response.status}: ${body}`);
}

const me = JSON.parse(body);
console.log(JSON.stringify({
  ok: true,
  accountUsername: result.account?.username,
  displayName: me.displayName,
  userPrincipalName: me.userPrincipalName,
  jobTitle: me.jobTitle,
  officeLocation: me.officeLocation,
}, null, 2));

Observed successful output shape

The successful run returned user profile fields from Graph. Do not commit or log tokens. Profile fields are enough to validate the flow.

{
  "ok": true,
  "accountUsername": "user@example.com",
  "displayName": "Example User",
  "userPrincipalName": "user@example.com",
  "jobTitle": "Principal SWE Manager",
  "officeLocation": "ATLANTA-170 17TH ST NW/Mobile"
}

How it works

  1. MSAL creates a public client application for the A365/VS Code client ID.
  2. NativeBrokerPlugin connects MSAL Node to the Windows Web Account Manager broker.
  3. getAllAccounts() asks the broker for accounts visible to that client ID.
  4. acquireTokenSilent() tries to use a discovered account without prompting.
  5. acquireTokenInteractive({ prompt: PromptValue.NONE }) lets WAM choose the existing signed-in account/session without showing full login UI when possible.
  6. If needed, broker interactive auth can show native account selection/consent UI.
  7. The returned token is a Graph audience token, so it can call https://graph.microsoft.com/v1.0/me.

Production guidance for Chamber

Graph profile import mapping

Chamber field Graph source Notes
Name /me.displayName Primary display name.
Work /me.jobTitle, fallback /me.companyName Keep this concise for Settings UI.
Location /me.officeLocation, fallback /me.city Office location may be more useful than city in enterprise tenants.
About Manual only Do not import About from Graph v1 /me; it is not reliably available.
Avatar /me/photos/96x96/$value or /me/photo/$value Prefer a sized endpoint or downscale before storing a data URL.

Photo fetch example

async function fetchGraphPhotoDataUrl(accessToken) {
  const photo = await fetch('https://graph.microsoft.com/v1.0/me/photos/96x96/$value', {
    headers: { Authorization: `Bearer ${accessToken}` },
  });

  if (photo.status === 404) {
    return null;
  }

  if (!photo.ok) {
    const requestId = photo.headers.get('request-id') ?? photo.headers.get('client-request-id');
    throw new Error(`Graph photo failed ${photo.status}${requestId ? ` request-id=${requestId}` : ''}`);
  }

  const contentType = photo.headers.get('content-type') ?? 'image/jpeg';
  const bytes = Buffer.from(await photo.arrayBuffer());
  return `data:${contentType};base64,${bytes.toString('base64')}`;
}

Known failure modes

Bottom line: use MSAL Node broker auth for Windows profile import. It gives Chamber a proper Graph token through WAM without stealing tokens from VS Code, Teams, Office, or GitHub Copilot.