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.
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:
@azure/msal-node: public client app and token acquisition APIs.@azure/msal-node-extensions: Windows native broker/WAM integration and optional persistent cache support.
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
- MSAL creates a public client application for the A365/VS Code client ID.
NativeBrokerPluginconnects MSAL Node to the Windows Web Account Manager broker.getAllAccounts()asks the broker for accounts visible to that client ID.acquireTokenSilent()tries to use a discovered account without prompting.acquireTokenInteractive({ prompt: PromptValue.NONE })lets WAM choose the existing signed-in account/session without showing full login UI when possible.- If needed, broker interactive auth can show native account selection/consent UI.
- The returned token is a Graph audience token, so it can call
https://graph.microsoft.com/v1.0/me.
Production guidance for Chamber
- Use broker/WAM first. Do not use device-code as the default Graph profile import path.
- Do not scrape or reuse another app's token cache. Ask the broker for a Chamber/MSAL token.
- Do not shell out to A365 tools. Chamber should own the Settings profile UX.
- Keep Graph import Settings-only in the first PR; user chat/avatar consumers can follow later.
- Store any Chamber-owned auth metadata under Chamber app data, such as
app.getPath('userData')\auth\microsoft\. - Never persist access tokens in config files, mind directories, or
.working-memory. - Log Graph request IDs on failures when available, but never log bearer tokens.
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
- Device-code flow: can return
You don't have access to thisfor this app/scope combination. - Silent broker token: may return
interaction_required; tryprompt=noneor broker interactive. - No photo: Graph commonly returns 404. Treat it as successful profile import without avatar.
- 401/403 from Graph: surface as an auth/tenant-policy error and include request ID if available.
- Native handle lifetime: spike scripts may keep Node alive after success because native broker handles remain open. Production code should run inside the app lifecycle; standalone scripts can be stopped after output.