ADR-0022: Microsoft Graph Auth Model — Dual Flows Behind a Token-Provider Protocol¶
Status¶
| Field | Value |
|---|---|
| Status | Accepted |
| Supersedes | — |
| Superseded by | — |
| Amends | — |
Revised 2026-06-03 in place rather than superseded — by the time the rewrite landed the ADR was unimplemented against, so there was no caller state to preserve and a superseding ADR would have added a level of indirection without aiding any reader. Per project rule, materially-changing ADRs would normally be superseded; this one was a user-authorised exception scoped to the four Graph ADRs (0021..0024).
Context¶
The Graph backend (ID-127, RFC-0010) has to work in two distinct operating environments:
- Daemon / service processes with no human at the keyboard, using an app registration plus client secret or certificate. OAuth 2.0 client-credentials flow.
- Interactive CLI / notebook sessions where a human can complete a login in a browser, typically using OAuth 2.0 device-code flow.
Both flows ultimately produce a bearer token that the backend attaches
to every Graph request as an Authorization: Bearer … header. The
backend does not care which flow produced the token — it only needs to
be able to obtain a current one.
Other credential sources (managed identity, workload identity, cached Azure CLI login) are out of scope for v1 but are likely to surface later through user-supplied providers.
Decision¶
The backend authenticates through a token-provider callable, not a concrete auth class. The decisions:
- Token-provider callable, two shapes. The backend accepts
Callable[[], str](sync) orCallable[[], Awaitable[str]](async) and never couples to MSAL through its constructor. Users who obtain tokens another way (managed identity, corporate broker, custom refresh) supply their own callable. Reverse (via a new ADR) only if the backend needs auth features a bare token-returning callable cannot express — per-request scope selection, token metadata, or tight MSAL coupling. - Built-in
GraphAuthhelper. Wraps MSAL and exposes both callable shapes, covering two flows: client-credentials (app-only, admin-consentedFiles.ReadWrite.All/Sites.ReadWrite.All) and device-code (delegated, interactive). GR-006 / GR-007 specify each flow's config fields. - Lazy invocation. The provider is called on first request and once
more on a
401 InvalidAuthenticationToken(one-shot refresh + retry, GR-029); the backend caches no token. Callers who bring their own provider load none ofmsal/msal-extensions/platformdirs. - Credential masking on two surfaces.
client_secretis aSecret(masked in__repr__) and auto-wrapped from config via_SENSITIVE_KEYS; theAuthorizationbearer is redacted from logs and never enters exception text. Mechanisms: GR-035, SEC-003 / SEC-004 / SEC-007. - Config-built backends get a default
GraphAuth. The registry builds one from static config (ADR-0001); user-supplied callables are expressible only through direct construction. Thegraphextra's pins live inpyproject.toml(ADR-0021 records the SDK choice).
Token cache: why PersistedTokenCache¶
GraphAuth persists the MSAL cache through
msal_extensions.PersistedTokenCache (a cross-process lock plus a
dirty-read retry, no atomic rename) and wraps it to swallow-and-log
persistence failures, so a cache error degrades to re-acquisition rather
than breaking an in-flight read / write. Two facts a reviewer needs
to keep or reverse this choice:
- It replaced a hand-rolled
SerializableTokenCache+ truncate-at-open flush, under which a concurrent reader could observe a torn cache and be forced to re-login (BK-291). - A bare temp-file +
os.replacewas rejected because on Windowsos.replaceraisesPermissionError(WinError 5) when the destination is held open by a concurrent reader; the lock-plus-read-retry design sidesteps rename entirely.
The cache path, override rules, and the multi-process-safety contract are
specified by GR-007; the persistence mechanism itself lives in
_graph/auth.py.
Consequences¶
- Testability. Unit tests stub the callable with a lambda returning a known string. No MSAL in the unit-test path.
- Extensibility without re-opening the backend API. Managed identity, workload identity, broker-based auth, or any future flow plug in as a user-supplied callable. Adding them does not change the backend constructor.
- MSAL stays an implementation detail of
GraphAuth. Users who bring their own callable do not needmsalinstalled — though it remains in thegraphextra because the defaultGraphAuthhelper uses it. - Two callable shapes to maintain. Sync and async variants must
both be supported. The backend is async-native (ADR-0012), so the
async shape is primary; the sync shape exists so sync-facing
wrapper code can reuse the same
GraphAuthinstance without an event loop. _SENSITIVE_KEYSwidened. Adding"client_secret"and"client_certificate"(ID-127) was a one-line config change with no behavioural impact on other backends (their config keys do not collide).
References¶
- RFC-0010: Microsoft Graph Backend (auth section)
sdd/specs/044-graph-backend.md(GR-006 through GR-008, GR-029, GR-035)sdd/specs/020-credential-hygiene.md(SEC-001 through SEC-008)- ADR-0001: Architecture — Store, Registry, Backends
- ADR-0012: Async Store / Backend API
- AF-008: backend
__repr__credential masking - MSAL Python token cache: https://learn.microsoft.com/entra/msal/python/msal.token_cache
- msal-extensions
PersistedTokenCache(cross-process cache lock): https://github.com/AzureAD/microsoft-authentication-extensions-for-python - BK-291: multi-process-safe token-cache persistence (lock + read-retry)