# Outlook Auth Reference

## Provenance

The device-code register flow is **bespoke to Real Agent**, not vendored. It is not the `nsakki55/outlook-mcp` loopback `authManager` — that ran an HTTP server on the box and required consent in a browser on the same machine, which is impossible on a headless/remote install. `device-flow.ts` talks to Microsoft's `/devicecode` and `/token` endpoints directly.

The **encrypted token store** (`token-store.ts`) is still adapted from [`nsakki55/outlook-mcp`](https://github.com/nsakki55/outlook-mcp) (commit `e49d8e68ac8d69db653c25803127dc3b98626cda`, MIT):

- Per-account paths (`{ACCOUNTS_DIR}/{accountId}/secrets/outlook/`) instead of upstream's single-user `~/.outlook-mcp/`
- `keytar` fallback removed (Pi runs headless; no Secret Service)
- `node-persist` removed; single JSON blob written atomically (temp + rename)

## Client ID model: one vendor app per brand

End users never register an Azure app. The `OUTLOOK_CLIENT_ID` is a **public, non-secret** value (a PKCE public-client app has no client secret), and the app is **multi-tenant** (`/common`), so **one vendor-owned Entra app per brand** serves every install and every mailbox on that brand. End users only complete the OAuth consent screen; per-mailbox tokens stay per-account and encrypted, so a shared client ID does not weaken isolation.

The value ships as the brand default: `brands/<brand>/brand.json#outlookClientId`. The installer stamps it onto the `-claude-session-manager` unit env as `OUTLOOK_CLIENT_ID` (+ `OUTLOOK_TENANT_ID=common`), and the plugin's MCP server reads it from `process.env` — it arrives by inheritance down the spawn chain (manager unit env → PTY → MCP child), not through any PLUGIN.md declaration. Outlook is `mcp-manifest: auto`, whose generator branch discards the declared `mcp.env` block entirely and emits only `MCP_SPAWN_TEE_NAME` (the manifest contract). The plugin's inert `OUTLOOK_CLIENT_ID: ${OUTLOOK_CLIENT_ID}` declaration has been deleted; it reached no path. A brand that ships the field empty has the plugin inert (register refuses) until its vendor app exists — no operator action makes it work.

**Per-install override (escape hatch, not required):** setting `OUTLOOK_CLIENT_ID` in the install's env overrides the brand default. This is only for a vendor testing a replacement app; normal installs need nothing.

### Vendor provisioning runbook (one-time per brand)

Run once per brand to mint the app whose ID goes in that brand's `brand.json`. Not an end-user step.

1. Sign in to the [Azure portal](https://portal.azure.com) with the brand's Microsoft account.
2. Go to **Microsoft Entra ID → App registrations → New registration**.
3. **Name:** the brand's product name plus "Outlook" (e.g. `SiteDesk Outlook`).
4. **Supported account types:** *Accounts in any organizational directory and personal Microsoft accounts* (multi-tenant is required — one app serves every end user's tenant).
5. Leave **Redirect URI** empty. The device-code flow does not use one.
6. Click **Register**. Copy the **Application (client) ID** — this is the brand's `outlookClientId`.
7. Go to **Authentication → Advanced settings → Allow public client flows** and set it to **Yes**. The device-code grant is a public-client flow and fails with `AADSTS7000218` if this is off.
8. Go to **API permissions → Add a permission → Microsoft Graph → Delegated permissions**, add:
   - `offline_access`
   - `User.Read`
   - `Mail.ReadWrite`
   - `Mail.Send`
   - `Calendars.Read`
   - `Contacts.Read`
9. Leave admin consent to the end user: with delegated scopes on a multi-tenant app, each end user consents for their own mailbox at first sign-in. (Frictionless cross-tenant consent is smoother once the app is publisher-verified via MPN — a separate enabling step.)
10. Set the value in the brand's `brand.json` and re-bundle:
   ```jsonc
   // brands/<brand>/brand.json
   "outlookClientId": "<the Application (client) ID copied above>"
   ```
   `OUTLOOK_TENANT_ID` is always `common`; the installer stamps it and no brand field is needed.

## Device-code flow (per-account)

Two tools: `outlook-account-register` starts the flow, `outlook-account-register-poll` completes it. No loopback server, no same-machine constraint — the operator consents on any device.

```
operator                                        plugin                              Microsoft
   |                                              |                                     |
   | outlook-account-register                     |                                     |
   |--------------------------------------------->| POST /devicecode (client_id+scope)  |
   |                                              | -----------------------------------> |
   |                                              | < device_code, user_code,           |
   |                                              |   verification_uri, expires_in,     |
   |                                              |   interval                          |
   |                                              | <----------------------------------- |
   |                                              | encrypt → write {pending-devicecode.enc}
   | { status: "pending", userCode,               |                                     |
   |   verificationUri, ... }                     |                                     |
   |<---------------------------------------------|                                     |
   |                                              |                                     |
   | open microsoft.com/devicelogin on ANY device |                                     |
   | enter userCode, sign in, consent ------------------------------------------------> |
   |                                              |                                     |
   | outlook-account-register-poll  (repeat)      |                                     |
   |--------------------------------------------->| POST /token (device_code grant)     |
   |                                              | -----------------------------------> |
   |                                              | < authorization_pending  → pending   |
   |                                              | < tokens (once consented) → success  |
   |                                              | <----------------------------------- |
   |                                              | GET /me  (resolve graphUserId + mail) |
   |                                              | encrypt → write mailboxes/<id>/tokens.enc |
   |                                              | clear pending (verified re-stat)     |
   | { status: "registered", graphUserId, ... }   |                                     |
   |<---------------------------------------------|                                     |
```

`poll` maps Microsoft's polling errors: `authorization_pending` → `pending` (wait `intervalSec`, poll again), `slow_down` → `pending` with a bumped interval, `expired_token` → `expired`, `access_denied` → `denied`.

**Server-driven completion.** The poll does not depend on an agent calling it. The always-running `platform/ui` server spawns a self-contained completion dispatcher (`mcp/dist/scripts/complete-registration.js`) on a 30 s interval; it enumerates every account holding a live pending device code and loops `runAccountRegisterPoll` to a terminal outcome, so a mailbox connects on its own once the operator consents. The dispatcher is PID-locked against overlap, inherits `OUTLOOK_CLIENT_ID` / `OUTLOOK_TENANT_ID` from the session-manager env, and is a no-op when the client id is unconfigured. `outlook-account-register-poll` remains the manual path and the fallback if the dispatcher is down. Observability: one `devicecode-autopoll account=<id> status=<status>` line per poll cycle, plus an independent server-side `devicecode-stranded account=<id> ageSec=<N>` audit that flags any `pending-devicecode.enc` older than the maximum code lifetime (the standing signal that the dispatcher is not running).

### Re-registering an account that already consented

First-time registration completes cleanly. Re-registering a mailbox whose Microsoft account has **already granted this app access** on a prior registration does not: after the operator enters the user code, Microsoft's consent step redirects the browser to `http://localhost/?error=invalid_request&error_description=...response_type...`, a "site can't be reached" dead-end, and consent never finishes. Observed for personal accounts (outlook.com, hotmail.com, live.com), whose completion runs through the consumer stack (`login.live.com` `msaidpvalidate` → `post.srf`).

This is Microsoft's re-consent behaviour, not a plugin fault. Confirmed 2026-07-14: our `/devicecode` request is byte-identical to a run that succeeded days earlier (only the account's consent state differed); the `response_type` the error names is absent because the device-code grant has no such parameter; and the malformed redirect is built by Microsoft's `post.srf`, not by anything the plugin sends. Removing the app's `http://localhost` reply URL does not help (the redirect uses a request-supplied `redirect_uri`, not a registered one), and the Implicit-grant/ID-token toggle does not apply (device-code is a public-client flow, not Web/SPA).

The block is the **stale Microsoft-side grant**, which lives in the end user's own Microsoft account, so there is no server-side tool to clear it. The operator revokes the app at **https://account.microsoft.com/privacy/app-access** (remove the brand's app entry), then re-runs `outlook-account-register`; the next sign-in is treated as first-time and completes. The already-connected mailbox keeps working the whole time — only the re-registration is blocked. See `.tasks/backlog/1634-outlook-account-deregister-and-reconsent-guidance.md` for the follow-up on a server-side deregister tool and a register-time hint.

## Token store layout

One account can hold several mailboxes. Each mailbox owns its own `tokens.enc`
under `mailboxes/<graphUserId>/`, and they share the one account-level AES `.key`.
Account isolation is unchanged — the key is per-account, never shared across
accounts — while mailboxes within one account share it. Per-account, mode 0700
directories + mode 0600 files:

```
data/accounts/<accountId>/secrets/outlook/
├── .key                              # 32 random bytes, base64 — the AES-256-CBC key, shared by every mailbox on this account
├── mailboxes/
│   ├── <graphUserId-A>/tokens.enc    # AES-256-CBC "<iv-hex>:<cipher-hex>" of mailbox A's token JSON blob
│   └── <graphUserId-B>/tokens.enc    # mailbox B — coexists, never overwritten by A
└── pending-devicecode.enc            # transient — the device_code between register + poll; cleared on any terminal outcome
```

**Migration.** A pre-multi-mailbox install has a single `tokens.enc` at the
account root. On first read the registry moves it into
`mailboxes/<graphUserId>/tokens.enc`. Because the AES key is unchanged, the
ciphertext is valid as-is — the migration is a file move, never a re-encrypt,
and never forces a re-auth.

**One registration at a time.** The pending file is single-slot per account and
reuses the same `.key`. `outlook-account-register` refuses with an `in-progress`
signal when a live device-code flow already exists, rather than overwriting it.
The entry is removed (verified by re-stat) when polling reaches `registered`,
`expired`, or `denied`, so a device_code never lingers. Genuinely-concurrent
registrations are out of scope — see `.tasks/1594`.

The JSON blob shape:

```typescript
{
  accessToken: string;
  refreshToken: string | null;
  accessTokenExpiry: number;        // unix ms
  refreshTokenExpiry: number;       // unix ms; ~90 days from issue
  lastRefresh: number;              // unix ms
  graphUserId: string | null;       // from /me.id — also the mailbox directory key
  mail: string | null;              // from /me.mail (or userPrincipalName) — the selectable address
  scopes: string[];                 // granted scopes from token response
}
```

Atomic write: temp + rename. Either old or new tokens, never partial. The temp
name embeds the writer's pid, so two processes writing the same mailbox never
share a temp path. A shared name meant one writer renamed the temp away and the
other's rename then failed with `ENOENT`.

## Mailbox selection

Every operational tool takes an optional `mailbox` argument (an email address or
graphUserId). Resolution order: explicit `mailbox` → the sole connected mailbox →
a typed refusal listing the connected mailboxes when two or more are attached and
none is named. Each resolution logs `cred-resolve account=<id> mailbox=<id>
source=arg|sole|none|refused`, so a wrong or absent selector is diagnosable from
one line. `outlook-mailbox-list` enumerates every mailbox on the account from the
local stores (no network) and flags any whose blob cannot be read.

## Refresh

Triggered automatically when the access token is within `5 minutes` of expiry
(`refreshThresholdMs`). Refresh tokens last 90 days.

**Only a revoked grant clears the tokens.** A refresh failure whose Azure body
carries `error=invalid_grant` — the refresh token revoked, expired, or already
consumed — logs `token-refresh-revoked`, clears the mailbox, and requires
`outlook-account-register`. Every other failure (network down, Microsoft 5xx, a
local write error) logs `token-refresh-retryable`, keeps the tokens, and
surfaces as retryable. Clearing on all of them used to destroy healthy mailboxes
over transient faults.

**One refresh per mailbox at a time, across processes.** Two Outlook MCP
processes on one account — the admin PTY conversation and a passive-channel
spawn, say — can decide the same mailbox needs refreshing at the same moment. A
lockfile at `<tokens.enc>.lock` admits one. The other waits up to 10 seconds for
the winner's token and never issues a refresh of its own, so whether Azure
invalidates a rotated refresh token never becomes load-bearing. A lock whose
holder process is dead, or which is older than 60 seconds, is stolen; that bound
sits well inside the 5-minute refresh window, so a stolen lock still leaves time
to refresh before the token actually expires.

The waiter keys on the access token *changing*, not on it merely being
un-expired. Under the 401 retry the stored token is locally fresh but Graph has
already rejected it, so a freshness check would hand back the bad token — the
failure this design exists to prevent.

Log events, for grepping a refresh incident:

| Event | Meaning |
|---|---|
| `token-refreshed` | This process refreshed successfully. |
| `token-refresh-revoked` | `invalid_grant`. Tokens cleared, re-registration required. |
| `token-refresh-retryable` | Transient failure. Tokens kept. |
| `token-refresh-deferred` | A peer held the lock; this process is waiting. |
| `token-refresh-superseded` | A peer refreshed first; this process adopted its token without a network call. |
| `token-refresh-peer-observed` | The waiter got the peer's new token. |
| `token-refresh-peer-failed` | The peer released the lock without publishing a token. |
| `token-refresh-peer-timeout` | The waiter gave up after 10 seconds. |

"Credentials wiped" and "transient write race" are separate events by design.
Only `token-refresh-revoked` means a mailbox actually needs re-registering.
