Skip to content

fix: prevent concurrent token cache corruption (JSONDecodeError + unbuffered I/O)#150

Open
rob-zz wants to merge 3 commits into
AzureAD:mainfrom
rob-zz:fix/concurrent-token-cache-corruption
Open

fix: prevent concurrent token cache corruption (JSONDecodeError + unbuffered I/O)#150
rob-zz wants to merge 3 commits into
AzureAD:mainfrom
rob-zz:fix/concurrent-token-cache-corruption

Conversation

@rob-zz

@rob-zz rob-zz commented Jul 6, 2026

Copy link
Copy Markdown

Problem

When multiple az login / token acquisition calls run concurrently (e.g. parallel CI jobs or Terraform runs), the MSAL extensions token cache can become corrupted. Two distinct but related bugs cause this:

  1. JSONDecodeError 'Extra data' – concurrent writers truncate or partially overwrite the cache file while another process is reading it.
  2. ValueError: can't have unbuffered text I/OCrossPlatLock opens the lock file with buffering=0 which is invalid for text mode in Python 3 (valid only for binary mode).

Both bugs manifest in azure-cli and, by extension, in Terraform (via the AzureRM / AzureAD providers which use the same MSAL token cache): Azure/azure-cli#30046

Terraform users typically see these errors when running terraform apply or terraform plan with the default parallelism (-parallelism=10): multiple provider goroutines call into the Azure SDK simultaneously, which all share the single on-disk MSAL token cache and trigger the same race conditions.

A third observable symptom is:

User '<upn>' does not exist in MSAL token cache

This appears because when _reload_if_necessary() fails due to a corrupted cache file, the in-memory cache is left empty (or not updated). MSAL's find_account() then returns None, and az-cli reports the user as absent — even though the user was previously logged in. This is not a separate bug: fixing the cache corruption eliminates this symptom too.

This was originally reported in the Terraform AzureRM provider as a "MSAL token (timing?) issue": hashicorp/terraform-provider-azurerm#27573

Root causes & fixes

1. CrossPlatLock uses buffering=0 in text mode (Python 3 incompatible)

cache_lock.py opened the lock file with open(..., 'w', buffering=0). Python 3 raises ValueError: can't have unbuffered text I/O for text-mode files with buffering=0. Fixed by removing the buffering argument (default buffering is fine for a lock file).

2. CrossPlatLock not used consistently / race conditions

token_cache.py's modify() acquires the lock after reading the cache but before writing. A concurrent writer can modify the file between the read and the write, causing the in-memory state to be stale, and the lock itself had the buffering bug above which caused it to crash before ever protecting anything.

3. FilePersistence.save writes directly, risking partial writes seen by concurrent readers

persistence.py's save() wrote directly to the target file. A concurrent reader could see a partial write, leading to JSON parse errors. Fixed by writing to a per-process temp file in the same directory and then os.replace()-ing it atomically.

Commits

  • fix: CrossPlatLock races that cause JSONDecodeError('Extra data') under concurrency
  • fix: FilePersistence.save uses atomic per-process temp file to prevent cache corruption
  • fix: remove buffering=0 from CrossPlatLock (invalid for text mode in Python 3)

Repro

Tested locally with azure-cli 2.87.0 / Python 3.13. The following script reliably triggers both errors:

az logout
rm ~/.azure/msal_token_cache.*
az login

for i in $(seq 1 300); do
  az account get-access-token \
    --scope "https://stasdgalsdjhca${i}.queue.core.windows.net/.default" \
    --subscription <subscription-id> \
    -o none &
done
wait

Without the fix: mix of JSONDecodeError: Extra data and ValueError: can't have unbuffered text I/O across the 300 parallel processes.
With the fix: all 300 processes complete successfully.

The same race conditions are triggered by terraform apply (default -parallelism=10) when using the AzureRM or AzureAD provider, manifesting as:

  • User '<upn>' does not exist in MSAL token cache (corrupted cache leaves in-memory state empty)

Related

rob-zz and others added 3 commits July 6, 2026 16:15
…er concurrency

Two races in the original CrossPlatLock design corrupted the shared MSAL
token cache when many processes (e.g. 300+ parallel az-cli invocations)
accessed it simultaneously:

Race 1 — LOCK_NB bypasses the lock entirely
  portalocker.LOCK_EX | portalocker.LOCK_NB raises LockError immediately
  when any other process holds the lock. The caller catches LockError and
  continues without the lock, so all but the first concurrent process skip
  the critical section and proceed to write the cache simultaneously.

Race 2 — lockfile deletion breaks POSIX inode-based exclusion
  __exit__ calls os.remove(lockfile). If process A deletes the file and
  process B immediately creates a new one at the same path, A and B now hold
  exclusive locks on *different inodes*. POSIX fcntl locks are per-(pid,inode)
  pair, so both hold a valid exclusive lock — mutual exclusion is lost.

Fix:
- Replace LOCK_EX | LOCK_NB with blocking LOCK_EX only.
  All concurrent processes now queue up and take turns; none bypass the lock.
- Open the lockfile in append mode ('a') so it is never truncated.
  This preserves the existing inode across the lifetime of the process group.
- Never delete the lockfile on __exit__.
  The file remains on disk across invocations; all processes open the same
  inode, so POSIX exclusion works correctly for the full session.
…t cache corruption

Defense-in-depth fix for FilePersistence.save, complementing the CrossPlatLock
fix in the previous commit.

The original code wrote directly to the cache file path:

    with os.fdopen(_open(self._location), 'w+') as handle:
        handle.write(content)

If two processes somehow bypass the lock (e.g. before the CrossPlatLock fix),
both write to the *same* temp path (${location}.tmp). A shorter write arriving
while a longer one is in progress leaves behind leftover bytes from the previous
longer write. When the cache is later read back, json.loads() sees two JSON
objects concatenated and raises:

    JSONDecodeError: Extra data: line N column C (char K)

Fix: write to a per-process, per-invocation unique temp file
(${location}.tmp.${pid}.${random_hex}) and then atomically rename it over the
target with os.replace(). Because os.replace() is atomic on POSIX, readers
always see either the old complete file or the new complete file — never a
partial write. The temp file is cleaned up on any exception.
…Python 3)

portalocker.Lock() defaults to mode='a' (text mode). Python 3 raises
ValueError: can't have unbuffered text I/O when buffering=0 is passed
with a text mode. Since the lockfile is never written to, buffering
has no effect and can be removed entirely.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@rob-zz

rob-zz commented Jul 6, 2026

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

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