Skip to content

Commit 7ffae02

Browse files
sellakumaranclaude
andauthored
Remove plaintext auth-token.json cache; rely on OS-protected MSAL cache (#451)
* Remove plaintext auth-token.json cache; rely on OS-protected MSAL cache Stop writing the plaintext L1 access-token cache (auth-token.json). Token persistence and silent refresh now rely solely on the OS-protected MSAL persistent cache (msal-token-cache: DPAPI on Windows, Keychain on macOS, owner-only file on Linux). forceRefresh is threaded to the MSAL credential (was a no-op once L1 is removed); login-hint resolution reads MSAL cache accounts instead of decoding a stored JWT; any legacy auth-token.json is deleted on cache-clear. Centralizes the msal-token-cache filename, fixes stale doc comments, and updates CHANGELOG + design.md. Build clean (warnings-as-errors); 1898 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Refine safe-token-cache: correct MSAL cache path, rename clearer, constructor migration cleanup - get-token reports the real MSAL cache path via MsalBrowserCredential.MsalCacheFilePath (fixes wrong ~/.config path on Linux/macOS — Copilot comment) - rename ClearStaleTokenCachesAsync -> ClearMsalCacheAsync (clears only the MSAL cache now) - run legacy auth-token.json cleanup once at construction; keep ClearCache hook - convert ClearCache tests to constructor-cleanup tests; correct CHANGELOG wording --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 091d51d commit 7ffae02

9 files changed

Lines changed: 239 additions & 326 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g
9595

9696
### Changed
9797

98+
- Hardened token storage: the CLI no longer writes access tokens to a plaintext file — they live only in the OS-protected MSAL cache (DPAPI/Keychain/owner-only file). Any legacy plaintext cache is removed automatically; sign-in prompts are unchanged.
9899
- `develop-mcp register-external-mcp-server` now sets `exit code 1` on failure paths (validation errors, tenant detection failure, Graph unavailable, Entra app creation failure, MCP-Platform AddMcpServer failure). Previously these paths logged an error and exited `0`, which made the command's success/failure status undetectable from scripts and CI. Successful dry-run and user-initiated cancellation at the y/N prompt continue to exit `0`.
99100
- Admin consent canary path (when the caller lacks `DelegatedPermissionGrant.Read.All`) no longer prompts for Enter immediately. The CLI now polls every 5 seconds, prints a friendly progress message at 30 seconds, and responds promptly to Enter or Ctrl+C. The previous jargon-heavy message about `oauth2PermissionGrants` was rewritten in plain English; technical details are demoted to `Debug`.
100101
- CLI log file now writes a run-start separator line (`====...====`) with the full command and timestamp before each invocation, making it easier to identify individual runs in a shared log file.

src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -252,9 +252,10 @@ private static async Task<McpServerTokenResult> AcquireTokenAsync(
252252
AuthenticationService authService,
253253
ILogger logger)
254254
{
255-
var tokenCachePath = Path.Combine(
256-
ConfigService.GetGlobalConfigDirectory(),
257-
AuthenticationConstants.TokenCacheFileName);
255+
// Report the OS-protected MSAL persistent cache as the token cache location. The CLI no
256+
// longer writes a plaintext app-level token file; MSAL owns token persistence (DPAPI on
257+
// Windows, Keychain on macOS, 0600 file on Linux).
258+
var tokenCachePath = MsalBrowserCredential.MsalCacheFilePath;
258259

259260
try
260261
{

src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,6 @@ public static string[] GetRequiredRedirectUris(string clientAppId)
8383
/// </summary>
8484
public const string ApplicationName = "Microsoft.Agents.A365.DevTools.Cli";
8585

86-
/// <summary>
87-
/// Token cache file name
88-
/// </summary>
89-
public const string TokenCacheFileName = "auth-token.json";
90-
9186
/// <summary>
9287
/// MSAL persistent token cache file name.
9388
/// Used by MsalBrowserCredential (WAM/browser auth) and referenced by

src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs

Lines changed: 86 additions & 230 deletions
Large diffs are not rendered by default.

src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,13 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Services;
2424
/// Uses Microsoft.Identity.Client.Extensions.Msal to persist tokens across all CLI instances.
2525
/// This dramatically reduces authentication prompts during multi-step operations like 'a365 setup all'.
2626
///
27-
/// Cache Location: [LocalApplicationData]/Agent365/msal-token-cache (Windows/macOS)
27+
/// Cache Location: [LocalApplicationData]/Microsoft.Agents.A365.DevTools.Cli/msal-token-cache (all platforms)
2828
/// Security: Tokens are stored using platform-appropriate mechanisms:
2929
/// - Windows: DPAPI (Data Protection API) - tokens encrypted with user credentials, persisted to disk
3030
/// - macOS: Keychain - tokens stored in secure keychain, persisted to disk
31-
/// - Linux: Shared in-memory cache (static, in-process) - tokens never written to disk, shared
32-
/// across all MsalBrowserCredential instances in the same CLI process to avoid repeated prompts
31+
/// - Linux: Unprotected plaintext file (0600 permissions, owner-only), persisted to disk.
32+
/// Same approach used by Azure CLI (~/.azure/msal_token_cache.json); tokens are protected
33+
/// by filesystem permissions rather than at-rest encryption.
3334
///
3435
/// See: https://learn.microsoft.com/en-us/entra/msal/dotnet/acquiring-tokens/desktop-mobile/wam
3536
/// Enhancement: Improves the WAM authentication experience by reducing repeated login prompts.
@@ -55,6 +56,16 @@ public sealed class MsalBrowserCredential : TokenCredential
5556
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
5657
AuthenticationConstants.ApplicationName);
5758

59+
/// <summary>
60+
/// Full path to the OS-protected MSAL persistent token cache file — the single source of
61+
/// truth for callers that report or clear the cache location. The cache lives under
62+
/// LocalApplicationData (%LocalAppData% on Windows, ~/.local/share on Linux,
63+
/// ~/Library/Application Support on macOS) + the application name. This is deliberately NOT
64+
/// ConfigService.GetGlobalConfigDirectory(), which resolves to the XDG config dir
65+
/// (~/.config/a365) on Linux/macOS and would report the wrong location.
66+
/// </summary>
67+
public static string MsalCacheFilePath => Path.Combine(CacheDirectory, CacheFileName);
68+
5869
// P/Invoke is required for WAM window handle in console applications.
5970
// There is no managed .NET API for console/desktop window handles - these are Windows-specific.
6071
// This is the standard approach documented by Microsoft for WAM integration:
@@ -267,6 +278,46 @@ private static void RegisterPersistentCache(IPublicClientApplication app, ILogge
267278
}
268279
}
269280

281+
/// <summary>
282+
/// Reads the username (UPN) of the first account in the MSAL persistent token cache without
283+
/// triggering any interactive authentication. Used to resolve a login hint for pre-selecting
284+
/// the correct account when the Azure CLI is not available.
285+
/// </summary>
286+
/// <param name="clientId">The application (client) ID whose cache to inspect — must match the
287+
/// client used for interactive acquisition so the same persisted accounts are visible.</param>
288+
/// <param name="logger">Optional logger for diagnostic output.</param>
289+
/// <returns>The UPN of the first cached account, or <c>null</c> if the cache is empty,
290+
/// unreadable, or any error occurs. This is a best-effort fallback and never throws.</returns>
291+
public static async Task<string?> TryGetCachedAccountUsernameAsync(string clientId, ILogger? logger)
292+
{
293+
if (string.IsNullOrWhiteSpace(clientId))
294+
{
295+
return null;
296+
}
297+
298+
try
299+
{
300+
// Build a minimal PublicClientApplication on the common authority. We never call any
301+
// Acquire* method here — only GetAccountsAsync, which reads the persisted cache and
302+
// cannot prompt. The authority/tenant does not affect which accounts are enumerated.
303+
var app = PublicClientApplicationBuilder
304+
.Create(clientId)
305+
.WithAuthority(AzureCloudInstance.AzurePublic, AuthenticationConstants.CommonTenantId)
306+
.Build();
307+
308+
RegisterPersistentCache(app, logger);
309+
310+
var accounts = await app.GetAccountsAsync();
311+
return accounts.FirstOrDefault()?.Username;
312+
}
313+
catch (Exception ex)
314+
{
315+
// Best-effort only — login-hint resolution falls back to no hint (account picker).
316+
logger?.LogDebug(ex, "Failed to read cached account username from MSAL cache");
317+
return null;
318+
}
319+
}
320+
270321

271322
/// <inheritdoc/>
272323
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)

src/Microsoft.Agents.A365.DevTools.Cli/design.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -182,12 +182,12 @@ All callers (GraphApiService, ArmApiService, TeamsGraphBackendConfigurator, ...)
182182
v
183183
AuthenticationService.GetAccessTokenAsync(resource, tenantId)
184184
|
185-
+-- Check persistent disk cache (%LocalAppData%\Agent365\token-cache.json)
186-
| Cache key: {resource}[:tenant:{tenantId}][:user:{userId}]
185+
+-- MsalBrowserCredential.GetTokenAsync(scopes) - silent-first against the
186+
| OS-protected MSAL cache (msal-token-cache: DPAPI/Keychain/owner-only file)
187187
|
188-
+-- Cache hit + not expiring: return token immediately (0 prompts)
188+
+-- Valid token in MSAL cache: returned silently (0 prompts)
189189
|
190-
+-- Cache miss / expired: MsalBrowserCredential.GetTokenAsync(scopes)
190+
+-- Miss / expired: interactive acquisition
191191
|
192192
+-- Windows: WAM broker (no browser, SSO, CAP-compliant)
193193
+-- macOS: System browser → device code fallback if restricted
@@ -205,7 +205,7 @@ AuthenticationService.GetAccessTokenAsync(resource, tenantId)
205205

206206
To prevent WAM from selecting a stale or wrong account, a login hint (UPN) is resolved before interactive auth:
207207
1. `AzCliHelper.ResolveLoginHintAsync()` — reads `az account show` if az CLI is present
208-
2. `AuthenticationService.ResolveLoginHintFromCacheAsync()`decodes `upn`/`preferred_username` from a cached JWT if az CLI is unavailable
208+
2. `AuthenticationService.ResolveLoginHintFromCacheAsync()`reads the first account's UPN from the OS-protected MSAL persistent cache if az CLI is unavailable
209209

210210
### IAuthenticationService Interface
211211

@@ -215,7 +215,7 @@ Only `GetAccessTokenAsync` and `ResolveLoginHintFromCacheAsync` are on the inter
215215

216216
### Token Caching
217217

218-
- **Persistent cache** (`AuthenticationService`): survives across CLI invocations, keyed by resource + tenant + user
218+
- **MSAL persistent cache** (`MsalBrowserCredential`): the sole token store - survives across CLI invocations, OS-protected at rest (DPAPI on Windows, Keychain on macOS, owner-only file on Linux). `AuthenticationService` no longer writes a separate cache.
219219
- **Process-level login hint cache** (`AzCliHelper`): caches the result of `az account show` for the process lifetime — invalidated after `az login` operations
220220

221221
### Platform Notes

src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Constants/AuthenticationConstantsTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ public void ApplicationName_ShouldBeCorrect()
3737
}
3838

3939
[Fact]
40-
public void TokenCacheFileName_ShouldBeCorrect()
40+
public void MsalCacheFileName_ShouldBeCorrect()
4141
{
42-
AuthenticationConstants.TokenCacheFileName.Should().Be("auth-token.json");
42+
AuthenticationConstants.MsalCacheFileName.Should().Be("msal-token-cache");
4343
}
4444

4545
[Fact]

src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AuthenticationServiceTests.cs

Lines changed: 79 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -32,35 +32,36 @@ public AuthenticationServiceTests()
3232
_mockLogger = Substitute.For<ILogger<AuthenticationService>>();
3333
_authService = new AuthenticationService(_mockLogger);
3434

35-
// Get the actual cache path that the service uses
35+
// The constructor best-effort deletes any legacy plaintext auth-token.json (migration cleanup).
36+
// These tests pin that behavior against the same legacy path the service computes internally.
3637
var appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
3738
_testCachePath = Path.Combine(appDataPath, "Microsoft.Agents.A365.DevTools.Cli", "auth-token.json");
3839
}
3940

4041
public void Dispose()
4142
{
42-
// Clean up test cache
43-
_authService.ClearCache();
43+
// Best-effort cleanup of any legacy file a test created (service no longer exposes ClearCache).
44+
try { if (File.Exists(_testCachePath)) File.Delete(_testCachePath); } catch (Exception) { /* best-effort */ }
4445
GC.SuppressFinalize(this);
4546
}
4647

4748
[Fact]
48-
public void ClearCache_WhenCacheExists_RemovesFile()
49+
public void Constructor_WhenLegacyPlaintextCacheExists_RemovesIt()
4950
{
50-
// Arrange
51+
// Arrange — simulate a plaintext auth-token.json left by an older CLI version
5152
var cacheDir = Path.GetDirectoryName(_testCachePath)!;
5253
Directory.CreateDirectory(cacheDir);
5354
File.WriteAllText(_testCachePath, "test content");
5455

55-
// Act
56-
_authService.ClearCache();
56+
// Act — constructing the service runs the one-time migration cleanup
57+
_ = new AuthenticationService(_mockLogger);
5758

5859
// Assert
5960
File.Exists(_testCachePath).Should().BeFalse();
6061
}
6162

6263
[Fact]
63-
public void ClearCache_WhenCacheDoesNotExist_DoesNotThrow()
64+
public void Constructor_WhenNoLegacyCache_DoesNotThrow()
6465
{
6566
// Arrange
6667
if (File.Exists(_testCachePath))
@@ -69,7 +70,7 @@ public void ClearCache_WhenCacheDoesNotExist_DoesNotThrow()
6970
}
7071

7172
// Act
72-
Action act = () => _authService.ClearCache();
73+
Action act = () => new AuthenticationService(_mockLogger);
7374

7475
// Assert
7576
act.Should().NotThrow();
@@ -801,13 +802,59 @@ public TestableAuthenticationService(
801802
_deviceCodeCredential = deviceCodeCredential;
802803
}
803804

804-
protected override TokenCredential CreateBrowserCredential(string clientId, string tenantId, string? loginHint = null)
805+
protected override TokenCredential CreateBrowserCredential(string clientId, string tenantId, string? loginHint = null, bool forceRefresh = false)
805806
=> _browserCredential;
806807

807808
protected override TokenCredential CreateDeviceCodeCredential(string clientId, string tenantId)
808809
=> _deviceCodeCredential;
809810
}
810811

812+
[Fact]
813+
public async Task GetAccessTokenAsync_OnSuccess_DoesNotWritePlaintextTokenFile()
814+
{
815+
// Regression guard for the Safe-Secrets fix: the service must NOT persist access tokens to
816+
// a plaintext file (the removed auth-token.json). Token persistence is delegated entirely
817+
// to the OS-protected MSAL persistent cache. We drive a successful acquisition through the
818+
// browser-credential seam (no real auth) and assert no plaintext token file appears in the
819+
// cache directory.
820+
var expectedToken = "stub-access-token";
821+
var browserCredential = new StubTokenCredential(expectedToken, DateTimeOffset.UtcNow.AddHours(1));
822+
var deviceCodeCredential = new StubTokenCredential("unused", DateTimeOffset.UtcNow.AddHours(1));
823+
824+
var logger = Substitute.For<ILogger<AuthenticationService>>();
825+
var sut = new TestableAuthenticationService(logger, browserCredential, deviceCodeCredential);
826+
827+
var cacheDir = Path.Combine(
828+
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
829+
AuthenticationConstants.ApplicationName);
830+
var legacyPlaintextPath = Path.Combine(cacheDir, "auth-token.json");
831+
832+
// Ensure no stale legacy file is present before the act.
833+
if (File.Exists(legacyPlaintextPath))
834+
{
835+
File.Delete(legacyPlaintextPath);
836+
}
837+
838+
try
839+
{
840+
// Act
841+
var result = await sut.GetAccessTokenAsync(
842+
"https://agent365.svc.cloud.microsoft",
843+
forceRefresh: true,
844+
useInteractiveBrowser: true);
845+
846+
// Assert — token returned, but nothing written to the plaintext cache path.
847+
result.Should().Be(expectedToken);
848+
File.Exists(legacyPlaintextPath).Should().BeFalse(
849+
because: "the Safe-Secrets fix removed the plaintext access-token cache; only the " +
850+
"OS-protected MSAL cache may persist tokens");
851+
}
852+
finally
853+
{
854+
sut.ClearCache();
855+
}
856+
}
857+
811858
[Fact]
812859
public async Task GetAccessTokenAsync_WhenBrowserThrowsPlatformNotSupported_FallsBackToDeviceCode()
813860
{
@@ -992,70 +1039,31 @@ private static string BuildJwt(object payload)
9921039
return $"header.{payloadB64Url}.signature";
9931040
}
9941041

995-
private void WriteTokenCache(string accessToken)
996-
{
997-
var cacheDir = Path.GetDirectoryName(_testCachePath)!;
998-
Directory.CreateDirectory(cacheDir);
999-
var cache = new
1000-
{
1001-
Tokens = new Dictionary<string, object>
1002-
{
1003-
["key"] = new { AccessToken = accessToken, ExpiresOn = DateTime.UtcNow.AddHours(1), TenantId = "tid" }
1004-
}
1005-
};
1006-
File.WriteAllText(_testCachePath, System.Text.Json.JsonSerializer.Serialize(cache));
1007-
}
1008-
1009-
[Fact]
1010-
public async Task ResolveLoginHintFromCacheAsync_WhenCacheHasUpnClaim_ReturnsUpn()
1011-
{
1012-
WriteTokenCache(BuildJwt(new { upn = "user@test.com" }));
1013-
1014-
var result = await _authService.ResolveLoginHintFromCacheAsync();
1015-
1016-
result.Should().Be("user@test.com",
1017-
because: "the upn claim in the cached JWT should be returned as the login hint");
1018-
}
1042+
// NOTE: ResolveLoginHintFromCacheAsync now reads the first account's UPN from the OS-protected
1043+
// MSAL persistent cache (DPAPI/Keychain/0600 file) via
1044+
// MsalBrowserCredential.TryGetCachedAccountUsernameAsync, rather than decoding a JWT from the
1045+
// removed plaintext auth-token.json. Populating that cache requires a real MSAL token exchange,
1046+
// which cannot run in a unit test without interactive auth. The contract verified here is the
1047+
// safe-degradation path: when no account is cached, the method must return null (a fallback
1048+
// behind az CLI) and never throw. End-to-end UPN resolution is covered by manual/integration runs.
10191049

10201050
[Fact]
1021-
public async Task ResolveLoginHintFromCacheAsync_WhenCacheHasPreferredUsernameClaim_ReturnsIt()
1022-
{
1023-
WriteTokenCache(BuildJwt(new { preferred_username = "user@contoso.com" }));
1024-
1025-
var result = await _authService.ResolveLoginHintFromCacheAsync();
1026-
1027-
result.Should().Be("user@contoso.com",
1028-
because: "preferred_username is the fallback claim when upn is absent");
1029-
}
1030-
1031-
[Fact]
1032-
public async Task ResolveLoginHintFromCacheAsync_WhenCacheFileDoesNotExist_ReturnsNull()
1051+
public async Task ResolveLoginHintFromCacheAsync_WhenNoAccountCached_ReturnsNullAndDoesNotThrow()
10331052
{
1053+
// Arrange — ensure no legacy plaintext cache lingers (the method no longer reads it anyway).
10341054
_authService.ClearCache();
10351055

1036-
var result = await _authService.ResolveLoginHintFromCacheAsync();
1037-
1038-
result.Should().BeNull(because: "no cache file means no login hint can be resolved");
1039-
}
1040-
1041-
[Fact]
1042-
public async Task ResolveLoginHintFromCacheAsync_WhenJwtHasNoUpnClaims_ReturnsNull()
1043-
{
1044-
WriteTokenCache(BuildJwt(new { sub = "some-subject", oid = "some-oid" }));
1045-
1046-
var result = await _authService.ResolveLoginHintFromCacheAsync();
1047-
1048-
result.Should().BeNull(because: "a JWT without upn or preferred_username cannot provide a login hint");
1049-
}
1050-
1051-
[Fact]
1052-
public async Task ResolveLoginHintFromCacheAsync_WhenJwtIsMalformed_ReturnsNull()
1053-
{
1054-
WriteTokenCache("not-a-valid-jwt");
1055-
1056-
var result = await _authService.ResolveLoginHintFromCacheAsync();
1057-
1058-
result.Should().BeNull(because: "a malformed JWT should be swallowed and null returned");
1056+
// Act
1057+
Func<Task<string?>> act = async () => await _authService.ResolveLoginHintFromCacheAsync();
1058+
1059+
// Assert — best-effort fallback: empty MSAL cache resolves to no hint, never an exception.
1060+
string? result = null;
1061+
await act.Should().NotThrowAsync(
1062+
because: "login-hint resolution is a fallback behind az CLI; an empty or unreadable MSAL " +
1063+
"cache must yield null rather than surfacing an error to the caller");
1064+
result = await _authService.ResolveLoginHintFromCacheAsync();
1065+
result.Should().BeNull(
1066+
because: "no cached MSAL account means no login hint can be resolved");
10591067
}
10601068

10611069
#endregion
@@ -1081,8 +1089,8 @@ public SequencedAuthenticationService(
10811089
: base(logger)
10821090
=> _credentials = new Queue<TokenCredential>(credentials);
10831091

1084-
protected override TokenCredential CreateBrowserCredential(string clientId, string tenantId, string? loginHint = null)
1085-
=> _credentials.Count > 0 ? _credentials.Dequeue() : base.CreateBrowserCredential(clientId, tenantId, loginHint);
1092+
protected override TokenCredential CreateBrowserCredential(string clientId, string tenantId, string? loginHint = null, bool forceRefresh = false)
1093+
=> _credentials.Count > 0 ? _credentials.Dequeue() : base.CreateBrowserCredential(clientId, tenantId, loginHint, forceRefresh);
10861094
}
10871095

10881096
// Computes the MSAL cache path so tests can back it up and restore it.

0 commit comments

Comments
 (0)