Auth — persist MSAL's token cache so interactive sign-in stops prompting hourly - #36
Merged
Merged
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
BrowserAuthandDeviceCodeAuthboth constructedmsal.PublicClientApplicationwithout atoken_cache=argument, so MSAL keptits cache in memory only. Every CLI invocation is a fresh process, which meant
get_accounts()always returned[]and theacquire_token_silent()callsitting 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 therefresh 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
browserordevice_codeprofile —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
New —
src/bcli/auth/_msal_cache.py:MsalTokenCache, a disk-backedwrapper around
msal.SerializableTokenCache._secure_io.write_secret_filepath — atomicreplace,
0600file,0700parent — the same treatmenttokens.jsonalready 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 tocall unconditionally after every acquisition.
instead of raising on every command.
tokens.jsonso the two can be reasoned about — anddeleted — independently.
Wired in —
_browser.py,_device_code.py: pass the cache to MSAL, persistafter both the silent and interactive paths (a silent refresh usually rotates
the refresh token).
Logout actually logs out —
clear_cache()on both providers andbcli auth logoutnow clear the MSAL cache too. Clearing only the access tokenwould 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()withinstance_discovery=Falseto keep logout workingoffline, and falls back to deleting the whole cache file on any failure —
over-logging-out is the safe direction.
bcli auth statusgained a line reporting whether silent renewal isavailable, since an expired access token no longer implies an interactive
prompt.
Docs —
docs/authentication.mdanddocs/configuration.mddocumented onecache; there are now two, with different lifetimes and different consequences.
Deliberately not done
msal_extensions(OS-keychain-backed persistence) would encrypt at rest onmacOS 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 acoordinated manifest change.
CHANGELOG.mdentry is under[Unreleased].Test plan
tests/test_auth/test_msal_cache.py(10 tests) — round-trip,0600permissions, 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 actualregression: 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.removing the
token_cache=argument turnstest_device_code_second_invocation_renews_without_promptingred, so itis guarding the real behaviour rather than passing vacuously.
tests/conftest.pyautouse fixture redirects the cache path totmp_path. Without it the suite would read and overwrite the developer'sreal
~/.config/bcli/msal_cache.json.ruff check src/ tests/clean.bcli auth statusrenders the newline correctly.
tenant by running
bcli auth login, waiting out the access-token TTL, andconfirming 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