Skip to content

Auth — persist MSAL's token cache so interactive sign-in stops prompting hourly - #36

Merged
igor-ctrl merged 1 commit into
mainfrom
fix/msal-cache-persistence
Aug 18, 2026
Merged

Auth — persist MSAL's token cache so interactive sign-in stops prompting hourly#36
igor-ctrl merged 1 commit into
mainfrom
fix/msal-cache-persistence

Conversation

@igor-ctrl

Copy link
Copy Markdown
Owner

Overview

BrowserAuth and DeviceCodeAuth both constructed
msal.PublicClientApplication without a token_cache= argument, so MSAL kept
its cache in memory only. Every CLI invocation is a fresh process, which meant
get_accounts() always returned [] and the acquire_token_silent() call
sitting in both flows was unreachable dead code. The only thing persisted was
the ~1 hour access token in tokens.json.

The visible result: a full browser or device-code round trip every time that
access token expired, all day long.

This persists MSAL's own cache to ~/.config/bcli/msal_cache.json, so the
refresh token survives between invocations and renewal happens silently.

Business Impact

Interactive sign-in drops from roughly hourly to roughly once per refresh-token
lifetime. This affects every human on a browser or device_code profile —
technical, finance and admin alike — and it is the single most-felt friction in
daily bcli use.

It also unblocks unattended and remote-approval workflows that were previously
impossible to script, because any loop longer than the access-token lifetime
would stall waiting for a browser that isn't there.

Client-credentials (service principal) profiles are unaffected — they mint
tokens from a secret on demand and have no refresh token to cache.

Changes

Newsrc/bcli/auth/_msal_cache.py: MsalTokenCache, a disk-backed
wrapper around msal.SerializableTokenCache.

  • Written through the existing _secure_io.write_secret_file path — atomic
    replace, 0600 file, 0700 parent — the same treatment tokens.json
    already receives. This file holds a refresh token, a longer-lived credential
    than anything previously written, so it reuses the hardened path rather than
    introducing a new one.
  • save() is a no-op unless MSAL actually mutated the cache, so it is cheap to
    call unconditionally after every acquisition.
  • A truncated, hand-edited or version-skewed cache degrades to "sign in again"
    instead of raising on every command.
  • Separate file from tokens.json so the two can be reasoned about — and
    deleted — independently.

Wired in_browser.py, _device_code.py: pass the cache to MSAL, persist
after both the silent and interactive paths (a silent refresh usually rotates
the refresh token).

Logout actually logs outclear_cache() on both providers and
bcli auth logout now clear the MSAL cache too. Clearing only the access token
would have left a usable refresh token on disk, so the next command would renew
silently and the "logout" would have been a lie. Removal goes through
remove_accounts() with instance_discovery=False to keep logout working
offline, and falls back to deleting the whole cache file on any failure —
over-logging-out is the safe direction.

bcli auth status gained a line reporting whether silent renewal is
available, since an expired access token no longer implies an interactive
prompt.

Docsdocs/authentication.md and docs/configuration.md documented one
cache; there are now two, with different lifetimes and different consequences.

Deliberately not done

msal_extensions (OS-keychain-backed persistence) would encrypt at rest on
macOS and Windows, but it is not currently a bcli dependency, its backends vary
by platform, and on headless Linux it degrades to a plain file anyway. Adding it
is a defensible follow-up; it is not required to fix the re-auth defect and
would widen the dependency surface of a tool that ships to a managed fleet. The
reasoning is recorded in the module docstring so the next reader doesn't have to
rederive it.

No version bump — the bootstrap pins bc-cli==0.8.2, so releasing this needs a
coordinated manifest change. CHANGELOG.md entry is under [Unreleased].

Test plan

  • tests/test_auth/test_msal_cache.py (10 tests) — round-trip, 0600
    permissions, no-op save when unchanged, corrupt-file tolerance, clear,
    account removal, path separation from tokens.json, valid JSON output.
  • tests/test_auth/test_silent_renewal.py (4 tests) — the actual
    regression: a second, independently constructed provider (standing in
    for the next CLI run) renews from the persisted refresh token and never
    prompts; the browser flow doesn't open a browser; the cache is actually
    handed to MSAL; and clear_cache() removes the refresh token.
  • Verified the regression test fails without the fix — temporarily
    removing the token_cache= argument turns
    test_device_code_second_invocation_renews_without_prompting red, so it
    is guarding the real behaviour rather than passing vacuously.
  • tests/conftest.py autouse fixture redirects the cache path to
    tmp_path. Without it the suite would read and overwrite the developer's
    real ~/.config/bcli/msal_cache.json.
  • Full suite: 1207 passed, 5 skipped.
  • ruff check src/ tests/ clean.
  • Read-only smoke against a real config: bcli auth status renders the new
    line correctly.
  • Reviewer check worth doing: confirm silent renewal end-to-end on a real
    tenant by running bcli auth login, waiting out the access-token TTL, and
    confirming the next command does not prompt. The unit tests stub MSAL, so
    they prove the wiring but not Entra's actual refresh behaviour.

https://claude.ai/code/session_01SfwmhstQdh7c9mB6w3mYUs

…ourly

BrowserAuth and DeviceCodeAuth both built msal.PublicClientApplication without
a token_cache=, so MSAL kept its cache in memory only. Every CLI invocation is
a fresh process, so get_accounts() always returned [] and the
acquire_token_silent() call sitting in both flows was unreachable dead code.
Only the ~1h access token in tokens.json was persisted, so users were pushed
through a full browser or device-code round trip every time it expired.

MSAL's own cache is now serialized to ~/.config/bcli/msal_cache.json via
bcli.auth.MsalTokenCache, written through the existing
_secure_io.write_secret_file path — atomic replace, 0600, 0700 parent — the
same treatment tokens.json already gets. The refresh token survives between
invocations, so renewal is silent and interactive sign-in drops to roughly once
per refresh-token lifetime. A corrupt or version-skewed cache degrades to
"sign in again" rather than raising.

auth logout and clear_cache() on both providers clear this cache too: dropping
only the access token would leave a usable refresh token on disk, so logout
would not have logged the user out. auth status gained a line reporting whether
silent renewal is available, since an expired access token no longer implies an
interactive prompt.

Client-credentials profiles are unaffected — a service principal mints tokens
from its secret on demand and has no refresh token to cache.

tests/conftest.py gains an autouse fixture redirecting the cache path to
tmp_path, so no test can read or overwrite a developer's real credential cache.

Claude-Session: https://claude.ai/code/session_01SfwmhstQdh7c9mB6w3mYUs
@igor-ctrl
igor-ctrl merged commit 357a6b8 into main Aug 18, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant