Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Interactive auth no longer prompts roughly once an hour. `BrowserAuth` and
`DeviceCodeAuth` both built `msal.PublicClientApplication` without a
`token_cache=`, so MSAL's cache existed only in memory. In a fresh process —
which is every CLI invocation — `get_accounts()` returned `[]`, the
`acquire_token_silent()` call sitting in both flows was unreachable dead code,
and only the ~1h access token in `tokens.json` was persisted. The visible
effect was a full browser or device-code round trip every time that access
token expired, all day long.

MSAL's own cache is now persisted to `~/.config/bcli/msal_cache.json`
(`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 silent renewal works 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.

`bcli auth logout` and `clear_cache()` on both providers now clear this cache
too — dropping only the access token would have left a usable refresh token on
disk, so "logout" would not have logged the user out. `bcli 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.

## [0.8.1] - 2026-08-11

### Fixed
Expand Down
24 changes: 20 additions & 4 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,30 @@ and bcli caches the resulting delegated token.

## Token Cache

After authentication, bcli caches access tokens at
`~/.config/bcli/tokens.json`. Tokens are reused until shortly before expiry.
bcli keeps two caches, both `0600` in a `0700` directory:

| File | Holds | Lifetime |
|------|-------|----------|
| `~/.config/bcli/tokens.json` | access tokens | ~1 hour (5-minute expiry buffer) |
| `~/.config/bcli/msal_cache.json` | MSAL's cache, incl. **refresh tokens** | until revoked or expired |

The second one is what keeps interactive auth rare. When an access token
expires, the `browser` and `device_code` flows renew silently from the cached
refresh token instead of prompting — so you sign in about once per
refresh-token lifetime, not once an hour.

```bash
bcli auth status
bcli auth logout
bcli auth status # reports whether silent renewal is available
bcli auth logout # clears both caches — after this, the next command prompts
```

`bcli auth logout` deliberately removes the MSAL cache too. Dropping only the
access token would leave the refresh token in place, so the next command would
renew silently and the "logout" would not have logged you out.

Client-credentials profiles are unaffected: a service principal mints a fresh
token from its secret on demand and has no refresh token to persist.

## Common Failures

| Symptom | Likely cause | Fix |
Expand Down
3 changes: 2 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@ logging.
| File | Purpose |
|------|---------|
| `~/.config/bcli/config.toml` | Main configuration |
| `~/.config/bcli/tokens.json` | Cached auth tokens |
| `~/.config/bcli/tokens.json` | Cached access tokens (~1h) |
| `~/.config/bcli/msal_cache.json` | MSAL cache incl. refresh tokens — enables silent renewal |
| `~/.config/bcli/registries/*.json` | Imported custom API registries |
| `~/.config/bcli/queries/*.yaml` | Saved queries |
| `~/.config/bcli/audit/*.jsonl` | Per-profile audit log (when `[audit] enabled = true`) |
Expand Down
9 changes: 8 additions & 1 deletion src/bcli/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
from bcli.auth._base import AuthProvider
from bcli.auth._browser import BrowserAuth
from bcli.auth._credentials import ClientCredentialsAuth
from bcli.auth._msal_cache import MsalTokenCache
from bcli.auth._token_cache import TokenCache

__all__ = ["AuthProvider", "BrowserAuth", "ClientCredentialsAuth", "TokenCache"]
__all__ = [
"AuthProvider",
"BrowserAuth",
"ClientCredentialsAuth",
"MsalTokenCache",
"TokenCache",
]
26 changes: 23 additions & 3 deletions src/bcli/auth/_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import msal

from bcli.auth._msal_cache import MsalTokenCache
from bcli.auth._token_cache import TokenCache
from bcli.config._defaults import BC_SCOPE, ENTRA_AUTHORITY_BASE
from bcli.errors import AuthError
Expand Down Expand Up @@ -103,10 +104,12 @@ def __init__(
token_cache: TokenCache | None = None,
login_hint: str | None = None,
incognito: bool = False,
msal_cache: MsalTokenCache | None = None,
) -> None:
self._tenant_id = tenant_id
self._client_id = client_id
self._token_cache = token_cache or TokenCache()
self._msal_cache = msal_cache or MsalTokenCache()
self._authority = f"{ENTRA_AUTHORITY_BASE}/{tenant_id}"
self._login_hint = login_hint
self._incognito = incognito
Expand All @@ -118,21 +121,29 @@ async def get_access_token(self) -> str:
if cached:
return cached

# Build MSAL public client
# Build MSAL public client. Passing token_cache is what lets the silent
# path below survive process exit: MSAL's default cache is in-memory,
# so without this get_accounts() is always empty in a fresh process and
# the user gets a browser prompt every time the ~1h access token dies.
app = msal.PublicClientApplication(
client_id=self._client_id,
authority=self._authority,
token_cache=self._msal_cache.cache,
)

# Try silent acquisition from MSAL in-memory cache
# Try silent acquisition — backed by the refresh token persisted by a
# previous invocation, not merely this process's memory.
accounts = app.get_accounts()
if accounts:
result = app.acquire_token_silent(
scopes=[BC_SCOPE],
account=accounts[0],
)
if result and "access_token" in result:
# A silent refresh usually rotates the refresh token; persist.
self._msal_cache.save()
self._cache_token(result)
logger.info("Renewed BC API token silently (no browser)")
return result["access_token"]

# Start localhost server on an ephemeral port BEFORE generating the
Expand Down Expand Up @@ -289,6 +300,7 @@ def log_message(self, format: str, *args: object) -> None:
error_desc = result.get("error_description", result.get("error", "Unknown error"))
raise AuthError(f"Token acquisition failed: {error_desc}", status_code=401)

self._msal_cache.save()
self._cache_token(result)
logger.info("Acquired BC API token via browser auth flow")
return result["access_token"]
Expand All @@ -300,5 +312,13 @@ def _cache_token(self, result: dict) -> None:
self._token_cache.put(self._tenant_id, self._client_id, access_token, expires_in)

def clear_cache(self) -> None:
"""Clear cached tokens for this tenant/client."""
"""Clear cached tokens for this tenant/client.

Clears the persisted MSAL cache too. Dropping only the access token
would leave the refresh token on disk, so a "logged out" user could
still renew silently.
"""
self._token_cache.clear(self._tenant_id, self._client_id)
self._msal_cache.remove_accounts(
client_id=self._client_id, authority=self._authority
)
26 changes: 23 additions & 3 deletions src/bcli/auth/_device_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import msal

from bcli.auth._msal_cache import MsalTokenCache
from bcli.auth._token_cache import TokenCache
from bcli.config._defaults import BC_SCOPE, ENTRA_AUTHORITY_BASE
from bcli.errors import AuthError
Expand All @@ -26,10 +27,12 @@ def __init__(
tenant_id: str,
client_id: str,
token_cache: TokenCache | None = None,
msal_cache: MsalTokenCache | None = None,
) -> None:
self._tenant_id = tenant_id
self._client_id = client_id
self._token_cache = token_cache or TokenCache()
self._msal_cache = msal_cache or MsalTokenCache()
self._authority = f"{ENTRA_AUTHORITY_BASE}/{tenant_id}"

async def get_access_token(self) -> str:
Expand All @@ -39,21 +42,29 @@ async def get_access_token(self) -> str:
if cached:
return cached

# Build MSAL public client (no client_secret needed)
# Build MSAL public client (no client_secret needed). token_cache is
# what makes the silent path below work at all: without it MSAL keeps
# its cache in memory, so a fresh process has no account and no refresh
# token, and every expiry costs the user another device-code prompt.
app = msal.PublicClientApplication(
client_id=self._client_id,
authority=self._authority,
token_cache=self._msal_cache.cache,
)

# Try silent acquisition first (MSAL in-memory cache from prior flows)
# Try silent acquisition first — now backed by the refresh token
# persisted from a previous invocation, not just this process.
accounts = app.get_accounts()
if accounts:
result = app.acquire_token_silent(
scopes=[BC_SCOPE],
account=accounts[0],
)
if result and "access_token" in result:
# A silent refresh usually rotates the refresh token; persist.
self._msal_cache.save()
self._cache_token(result)
logger.info("Renewed BC API token silently (no prompt)")
return result["access_token"]

# Initiate device code flow
Expand All @@ -75,6 +86,7 @@ async def get_access_token(self) -> str:
error_desc = result.get("error_description", result.get("error", "Unknown error"))
raise AuthError(f"Device code auth failed: {error_desc}", status_code=401)

self._msal_cache.save()
self._cache_token(result)
logger.info("Acquired BC API token via device code flow")
return result["access_token"]
Expand All @@ -86,5 +98,13 @@ def _cache_token(self, result: dict) -> None:
self._token_cache.put(self._tenant_id, self._client_id, access_token, expires_in)

def clear_cache(self) -> None:
"""Clear cached tokens for this tenant/client."""
"""Clear cached tokens for this tenant/client.

Clears the persisted MSAL cache too. Dropping only the access token
would leave the refresh token on disk, so a "logged out" user could
still renew silently.
"""
self._token_cache.clear(self._tenant_id, self._client_id)
self._msal_cache.remove_accounts(
client_id=self._client_id, authority=self._authority
)
152 changes: 152 additions & 0 deletions src/bcli/auth/_msal_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""Persistent MSAL token cache, so refresh tokens survive between invocations.

``BrowserAuth`` and ``DeviceCodeAuth`` both call ``acquire_token_silent()``
before falling back to an interactive prompt. That call can only ever succeed
if MSAL has an account and a refresh token to work with — and MSAL keeps those
in a cache that is **in-memory by default**. Construct
``msal.PublicClientApplication`` without ``token_cache=`` and every new process
starts blind: ``get_accounts()`` returns ``[]``, the silent path is dead code,
and the user re-authenticates interactively as soon as the ~1h access token in
:class:`bcli.auth.TokenCache` expires.

Persisting MSAL's own cache fixes that. The refresh token is reused silently and
the interactive prompt drops to roughly once per refresh-token lifetime.

Security posture
----------------
This file holds a **refresh token**, which is a longer-lived credential than
anything in ``tokens.json``. It is written through
:func:`bcli.auth._secure_io.write_secret_file` — atomic replace, ``0600``, in a
``0700`` parent — the same path the access-token cache already uses. That is a
deliberate consistency choice rather than a new mechanism.

We do **not** reach for ``msal_extensions`` (OS-keychain-backed persistence).
It would encrypt at rest on macOS/Windows, but it is not currently a bcli
dependency, its persistence 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.
"""

from __future__ import annotations

import logging
from pathlib import Path

import msal

from bcli.auth._secure_io import warn_if_insecure_perms, write_secret_file
from bcli.config._defaults import MSAL_CACHE_FILE

logger = logging.getLogger(__name__)


class MsalTokenCache:
"""Disk-backed wrapper around ``msal.SerializableTokenCache``.

Pass :attr:`cache` to ``msal.PublicClientApplication(token_cache=...)``,
then call :meth:`save` after any token acquisition. ``save()`` is a no-op
when MSAL didn't change anything, so it is cheap to call unconditionally.
"""

def __init__(self, cache_file: Path | None = None) -> None:
self._file = cache_file or MSAL_CACHE_FILE
self._cache: msal.SerializableTokenCache | None = None

@property
def cache(self) -> msal.SerializableTokenCache:
"""The live MSAL cache, deserialized from disk on first access."""
if self._cache is None:
self._cache = self._load()
return self._cache

def _load(self) -> msal.SerializableTokenCache:
cache = msal.SerializableTokenCache()
if not self._file.is_file():
return cache

warn_if_insecure_perms(self._file)
try:
cache.deserialize(self._file.read_text(encoding="utf-8"))
except Exception as exc:
# A truncated, hand-edited or version-skewed cache must degrade to
# "you need to sign in again", never to a traceback on every
# command. Deliberately broad: MSAL does not document a single
# exception type for deserialize failures.
logger.debug("Ignoring unreadable MSAL cache %s: %s", self._file, exc)
return cache

def save(self) -> bool:
"""Persist the cache if MSAL mutated it. Returns True if written."""
cache = self.cache
if not cache.has_state_changed:
return False

write_secret_file(self._file, cache.serialize())
# MSAL clears this flag only inside its own persistence helpers, so we
# clear it here — otherwise every later save() rewrites the same bytes.
cache.has_state_changed = False
return True

def clear(self) -> None:
"""Delete the persisted cache and reset the in-memory copy."""
self._cache = msal.SerializableTokenCache()
try:
self._file.unlink()
except FileNotFoundError:
pass
except OSError as exc:
logger.debug("Could not remove MSAL cache %s: %s", self._file, exc)

def remove_accounts(self, *, client_id: str, authority: str) -> int:
"""Sign out every cached account for this client, and persist.

Used by ``bcli auth logout``. Dropping the access token alone would
leave the refresh token on disk, so a "logged out" user could keep
minting tokens silently — this closes that.

Returns the number of accounts removed. On any failure we fall back to
deleting the whole cache file: over-logging-out is the safe direction.
"""
try:
app = msal.PublicClientApplication(
client_id=client_id,
authority=authority,
token_cache=self.cache,
# Keep logout usable offline. Instance discovery would other-
# wise reach login.microsoftonline.com just to forget a local
# credential.
instance_discovery=False,
)
accounts = app.get_accounts()
for account in accounts:
app.remove_account(account)

if accounts:
# remove_account() mutates the cache but does not reliably set
# has_state_changed, so write unconditionally here.
write_secret_file(self._file, self.cache.serialize())
self.cache.has_state_changed = False
return len(accounts)
except Exception as exc:
logger.debug(
"Falling back to deleting %s after remove_accounts failed: %s",
self._file,
exc,
)
self.clear()
return 0

def has_accounts(self, *, client_id: str, authority: str) -> bool:
"""True if a silent refresh could plausibly succeed. Best-effort."""
try:
app = msal.PublicClientApplication(
client_id=client_id,
authority=authority,
token_cache=self.cache,
instance_discovery=False,
)
return bool(app.get_accounts())
except Exception as exc:
logger.debug("Could not inspect MSAL cache: %s", exc)
return False
Loading
Loading