diff --git a/src/Maestro/Maestro.Common/AppCredentials/AppCredential.cs b/src/Maestro/Maestro.Common/AppCredentials/AppCredential.cs index 1c7ff52abe..011cfa1dc3 100644 --- a/src/Maestro/Maestro.Common/AppCredentials/AppCredential.cs +++ b/src/Maestro/Maestro.Common/AppCredentials/AppCredential.cs @@ -50,7 +50,12 @@ public static AppCredential CreateUserCredential(string appId, string userScope public static AppCredential CreateUserCredential(string appId, TokenRequestContext requestContext) { var authRecordPath = Path.Combine(AUTH_CACHE, $"{AUTH_RECORD_PREFIX}-{appId}"); - var credential = GetInteractiveCredential(appId, authRecordPath); + var interactiveCredential = GetInteractiveCredential(appId, authRecordPath); + // Interactive credential is primary; AzureCliCredential is a last-resort fallback for + // environments where interactive auth is completely unavailable (e.g. WSL without keyring + // AND no browser). The interactive credential uses cached auth records for silent token + // renewal, so it won't re-prompt when a valid cache exists. + var credential = new ChainedTokenCredential(interactiveCredential, new AzureCliCredential()); return new AppCredential(credential, requestContext); } diff --git a/src/Maestro/Maestro.Common/AppCredentials/CachedInteractiveBrowserCredential.cs b/src/Maestro/Maestro.Common/AppCredentials/CachedInteractiveBrowserCredential.cs index 915aa96215..2523f6d003 100644 --- a/src/Maestro/Maestro.Common/AppCredentials/CachedInteractiveBrowserCredential.cs +++ b/src/Maestro/Maestro.Common/AppCredentials/CachedInteractiveBrowserCredential.cs @@ -52,6 +52,58 @@ public override AccessToken GetToken(TokenRequestContext requestContext, Cancell { CacheAuthenticationRecord(requestContext, cancellationToken); + try + { + return GetTokenCore(requestContext, cancellationToken); + } + catch (Exception e) when (IsMsalCachePersistenceException(e)) + { + RecreateCredentialsWithoutPersistence(); + try + { + return GetTokenCore(requestContext, cancellationToken); + } + catch (AuthenticationFailedException retryEx) + when (!cancellationToken.IsCancellationRequested && !ContainsCancellationException(retryEx)) + { + // After persistence fallback, if interactive auth still fails due to environment issues + // (e.g. no browser), signal credential unavailability so ChainedTokenCredential can + // try the next credential (e.g. AzureCliCredential). User-initiated cancellations + // propagate directly so the caller sees the real failure. + throw new CredentialUnavailableException( + "Interactive authentication failed after token cache persistence fallback. " + + "Ensure a browser or device code flow is available, or use 'az login' as a fallback.", retryEx); + } + } + } + + public override async ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + CacheAuthenticationRecord(requestContext, cancellationToken); + + try + { + return await GetTokenCoreAsync(requestContext, cancellationToken); + } + catch (Exception e) when (IsMsalCachePersistenceException(e)) + { + RecreateCredentialsWithoutPersistence(); + try + { + return await GetTokenCoreAsync(requestContext, cancellationToken); + } + catch (AuthenticationFailedException retryEx) + when (!cancellationToken.IsCancellationRequested && !ContainsCancellationException(retryEx)) + { + throw new CredentialUnavailableException( + "Interactive authentication failed after token cache persistence fallback. " + + "Ensure a browser or device code flow is available, or use 'az login' as a fallback.", retryEx); + } + } + } + + private AccessToken GetTokenCore(TokenRequestContext requestContext, CancellationToken cancellationToken) + { if (Volatile.Read(ref _isDeviceCodeFallback) == 1) { return _deviceCodeCredential.GetToken(requestContext, cancellationToken); @@ -68,10 +120,8 @@ public override AccessToken GetToken(TokenRequestContext requestContext, Cancell } } - public override async ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + private async ValueTask GetTokenCoreAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) { - CacheAuthenticationRecord(requestContext, cancellationToken); - if (Volatile.Read(ref _isDeviceCodeFallback) == 1) { return await _deviceCodeCredential.GetTokenAsync(requestContext, cancellationToken); @@ -109,9 +159,6 @@ private void CacheAuthenticationRecord(TokenRequestContext requestContext, Cance Directory.CreateDirectory(authRecordDir); } - static bool IsMsalCachePersistenceException(Exception e) => - e is MsalCachePersistenceException || (e.InnerException is not null && IsMsalCachePersistenceException(e.InnerException)); - AuthenticationRecord authRecord; try { @@ -121,16 +168,7 @@ static bool IsMsalCachePersistenceException(Exception e) => catch (Exception e) when (IsMsalCachePersistenceException(e)) { // If we cannot persist the token cache, fall back to interactive authentication without persistence - _browserCredential = new InteractiveBrowserCredential(new InteractiveBrowserCredentialOptions() - { - TenantId = _options.TenantId, - ClientId = _options.ClientId, - }); - _deviceCodeCredential = new DeviceCodeCredential(new() - { - TenantId = _options.TenantId, - ClientId = _options.ClientId, - }); + RecreateCredentialsWithoutPersistence(); authRecord = Authenticate(requestContext, cancellationToken); } @@ -153,4 +191,25 @@ private AuthenticationRecord Authenticate(TokenRequestContext requestContext, Ca return _deviceCodeCredential.Authenticate(requestContext, cancellationToken); } } + + private void RecreateCredentialsWithoutPersistence() + { + _browserCredential = new InteractiveBrowserCredential(new InteractiveBrowserCredentialOptions() + { + TenantId = _options.TenantId, + ClientId = _options.ClientId, + AuthenticationRecord = _options.AuthenticationRecord, + }); + _deviceCodeCredential = new DeviceCodeCredential(new() + { + TenantId = _options.TenantId, + ClientId = _options.ClientId, + }); + } + + private static bool IsMsalCachePersistenceException(Exception e) => + e is MsalCachePersistenceException || (e.InnerException is not null && IsMsalCachePersistenceException(e.InnerException)); + + private static bool ContainsCancellationException(Exception e) => + e is OperationCanceledException || (e.InnerException is not null && ContainsCancellationException(e.InnerException)); }