Skip to content

Bug: a365 publish reuses expired MOS tokens due to DateTime timezone comparison in TryGetCachedToken #277

@pratapladhani

Description

@pratapladhani

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:

  1. DateTime.TryParse("2026-02-18T17:00:00.0000000Z") with default DateTimeStyles.None recognizes the Z as UTC, then converts to local time and returns Kind=Local
  2. On an IST machine (+5:30): parsed value = 2026-02-18T22:30:00 (Kind=Local)
  3. DateTime.UtcNow returns Kind=Utc — when comparing DateTimes of different Kind, .NET compares raw tick values with no timezone conversion
  4. At 18:00 UTC: 18:00 < 22:28true → 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\ where a365.generated.config.json lives
  • 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

  1. Set machine timezone to IST (UTC+5:30) or any timezone ahead of UTC
  2. Run a365 publish successfully (acquires fresh token, caches it)
  3. Wait for token to expire (~1 hour)
  4. Run a365 publish again
  5. Observe: Log shows "Using cached MOS token (valid until ...)" with the expired token
  6. 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:

  1. 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
  2. Set timezone to UTC temporarily before running a365 publish
  3. Use a365 publish --mos-token <token> with a freshly acquired token (bypasses cache entirely)

Metadata

Metadata

Assignees

Labels

P1Very high prioritybugSomething isn't workingsecuritySecurity-related issue

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions