-
Notifications
You must be signed in to change notification settings - Fork 10
Description
Summary
a365 publish reuses expired MOS tokens from cache due to a timezone-sensitive DateTime comparison bug in MosTokenService.TryGetCachedToken. This causes persistent HTTP 401 errors from titles.prod.mos.microsoft.com after the initial token expires (~1 hour). The issue is always reproducible on machines with timezones ahead of UTC (IST, JST, AEST, etc.) and cannot be resolved by deleting CLI cache files because the token cache is in a different location than users expect.
CLI Version: 1.1.91-preview+989a6a90f3
Platforms: Windows 11, macOS (cross-platform — timezone dependent)
Root Cause Analysis
Bug 1: DateTime timezone mismatch in TryGetCachedToken
In MosTokenService.cs line ~185, tokenexpiry is stored as UTC ISO 8601 ("2026-02-18T17:00:00.0000000Z" via .ToString("o")), but read back with bare DateTime.TryParse():
// TryGetCachedToken - CURRENT (BUGGY)
if (DateTime.TryParse(expiryStr, out var expiry))
{
if (DateTime.UtcNow < expiry.AddMinutes(-2))
{
return (token, expiry); // Returns stale token!
}
}The problem:
DateTime.TryParse("2026-02-18T17:00:00.0000000Z")with defaultDateTimeStyles.Nonerecognizes theZas UTC, then converts to local time and returnsKind=Local- On an IST machine (+5:30): parsed value =
2026-02-18T22:30:00(Kind=Local) DateTime.UtcNowreturnsKind=Utc— when comparing DateTimes of differentKind, .NET compares raw tick values with no timezone conversion- At 18:00 UTC:
18:00 < 22:28→true→ stale token returned!
Impact by timezone:
| Timezone | UTC Offset | Extra hours token appears "valid" | Severity |
|---|---|---|---|
| UTC | +0:00 | 0 (works correctly) | None |
| IST | +5:30 | ~5.5 hours | High |
| JST | +9:00 | ~9 hours | High |
| AEST | +10:00 | ~10 hours | High |
| PST | -8:00 | Expires ~8 hours early (extra auth prompts) | Low |
| EST | -5:00 | Expires ~5 hours early | Low |
Bug 2: Cache file location mismatch in error guidance
The MOS token cache is stored at ~/.a365/mos-token-cache.json (via FileHelper.GetSecureCrossOsDirectory()), but:
- Users naturally look in
%LOCALAPPDATA%\Microsoft.Agents.A365.DevTools.Cli\wherea365.generated.config.jsonlives - The 401 troubleshooting message (line ~305) says
"Delete: .mos-token-cache.json"without the full path - Deleting files from the wrong location has no effect — the stale cache persists
Complete Call Chain
PublishCommand.SetHandler
→ new MosTokenService(logger, configService)
→ mosTokenService.AcquireTokenAsync(mosEnv, mosPersonalToken)
→ TryGetCachedToken(environment) // ← Bug is here
→ File.ReadAllText("~/.a365/mos-token-cache.json")
→ DateTime.TryParse(expiryStr, out var expiry) // ← Converts UTC→Local
→ DateTime.UtcNow < expiry.AddMinutes(-2) // ← Compares UTC vs Local raw ticks
→ returns (staleToken, expiry) // ← Returns expired token
→ HttpClientFactory.CreateAuthenticatedClient(staleToken)
→ http.PostAsync(packagesUrl, form) // ← 401 Unauthorized
Steps to Reproduce
- Set machine timezone to IST (UTC+5:30) or any timezone ahead of UTC
- Run
a365 publishsuccessfully (acquires fresh token, caches it) - Wait for token to expire (~1 hour)
- Run
a365 publishagain - Observe: Log shows
"Using cached MOS token (valid until ...)"with the expired token - Result: HTTP 401 from titles.prod.mos.microsoft.com
Proposed Fix
Fix 1: Use DateTimeStyles.AdjustToUniversal in TryGetCachedToken
// TryGetCachedToken - FIXED
if (DateTime.TryParse(expiryStr,
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AdjustToUniversal,
out var expiry))
{
// expiry is now Kind=Utc, comparison with DateTime.UtcNow is correct
if (DateTime.UtcNow < expiry.AddMinutes(-2))
{
return (token, expiry);
}
}Alternative: Use DateTimeOffset.TryParse which preserves timezone information:
if (DateTimeOffset.TryParse(expiryStr, out var expiryOffset))
{
if (DateTimeOffset.UtcNow < expiryOffset.AddMinutes(-2))
{
return (expiryOffset.UtcDateTime.ToString(), expiryOffset.UtcDateTime);
}
}Fix 2: Show full cache path in error messages
In the 401 troubleshooting block (~line 305):
// BEFORE:
logger.LogError(" - Delete: .mos-token-cache.json");
// AFTER:
var cacheDir = FileHelper.GetSecureCrossOsDirectory();
var cachePath = Path.Combine(cacheDir, "mos-token-cache.json");
logger.LogError(" - Delete: {CachePath}", cachePath);Note on --mos-token override
The --mos-token override path in AcquireTokenAsync appears correct at the code level — when personalToken is non-null/non-whitespace, it returns immediately without checking cache. If users report 401 even with --mos-token, the issue may be that the manually-acquired token has the wrong audience/scope for the MOS Titles API, not that the override is ignored.
Workaround
Until the fix is released, users can:
- Delete the correct cache file:
~/.a365/mos-token-cache.json(not%LOCALAPPDATA%\...)- Windows:
del %USERPROFILE%\.a365\mos-token-cache.json - macOS/Linux:
rm ~/.a365/mos-token-cache.json
- Windows:
- Set timezone to UTC temporarily before running
a365 publish - Use
a365 publish --mos-token <token>with a freshly acquired token (bypasses cache entirely)