diff --git a/src/Maestro/Maestro.Common/AppCredentials/AppCredential.cs b/src/Maestro/Maestro.Common/AppCredentials/AppCredential.cs index efa3e565c6..46035e1dcc 100644 --- a/src/Maestro/Maestro.Common/AppCredentials/AppCredential.cs +++ b/src/Maestro/Maestro.Common/AppCredentials/AppCredential.cs @@ -51,7 +51,12 @@ public static AppCredential CreateUserCredential(string appId, string userScope public static AppCredential CreateUserCredential(string appId, TokenRequestContext requestContext, ILoggerFactory? loggerFactory = null) { var authRecordPath = Path.Combine(AUTH_CACHE, $"{AUTH_RECORD_PREFIX}-{appId}"); - var credential = GetInteractiveCredential(appId, authRecordPath, loggerFactory); + var interactiveCredential = GetInteractiveCredential(appId, authRecordPath, loggerFactory); + // 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 b1129f0a40..0bb031a6f4 100644 --- a/src/Maestro/Maestro.Common/AppCredentials/CachedInteractiveBrowserCredential.cs +++ b/src/Maestro/Maestro.Common/AppCredentials/CachedInteractiveBrowserCredential.cs @@ -50,13 +50,143 @@ public CachedInteractiveBrowserCredential( TenantId = _options.TenantId, ClientId = _options.ClientId, TokenCachePersistenceOptions = _options.TokenCachePersistenceOptions, + DeviceCodeCallback = (info, _) => + { + // Surface the device code through the same logger the rest of the auth flow uses; + // the default callback writes to AzureEventSource which is not visible in CLI/MCP hosts. + _logger.LogInformation("{Message}", info.Message); + return Task.CompletedTask; + }, }); + + // On WSL the interactive browser flow only succeeds when a Windows-side browser + // launcher (wslu's `wslview`) is installed AND WSL2 localhost forwarding can route + // the OAuth redirect back into the WSL network namespace. With wslu present that + // path works on default NAT-mode WSL2. Without wslu, xdg-open silently does nothing + // and `_browserCredential.Authenticate()` blocks forever waiting for a redirect + // that will never arrive. In that specific case, skip straight to device code so + // the user at least sees a code rather than an indefinite hang. + if (IsWslWithoutBrowserLauncher()) + { + Interlocked.Exchange(ref _isDeviceCodeFallback, 1); + } + } + + private static bool IsWslWithoutBrowserLauncher() + { + // Opt-out: user knows their setup supports browser auth even if we don't detect it. + if (string.Equals(Environment.GetEnvironmentVariable("DARC_FORCE_BROWSER_AUTH"), "1", StringComparison.Ordinal)) + { + return false; + } + + // Opt-in: user explicitly wants device code regardless of environment. + if (string.Equals(Environment.GetEnvironmentVariable("DARC_USE_DEVICE_CODE"), "1", StringComparison.Ordinal)) + { + return true; + } + + bool isWsl = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WSL_DISTRO_NAME")) + || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WSL_INTEROP")); + if (!isWsl) + { + return false; + } + + // On WSL: only skip browser flow if no usable launcher is on PATH. + // wslview (from the wslu package) is the canonical Windows-browser bridge. + // BROWSER env var override is also respected by Azure.Identity's launcher. + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("BROWSER"))) + { + return false; + } + return !ExistsOnPath("wslview"); + } + + private static bool ExistsOnPath(string executable) + { + var path = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrEmpty(path)) + { + return false; + } + foreach (var dir in path.Split(Path.PathSeparator)) + { + if (string.IsNullOrEmpty(dir)) + { + continue; + } + try + { + if (File.Exists(Path.Combine(dir, executable))) + { + return true; + } + } + catch + { + // Ignore inaccessible PATH entries. + } + } + return false; } public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) { 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); @@ -73,10 +203,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); @@ -114,9 +242,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 { @@ -126,16 +251,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); } @@ -147,6 +263,16 @@ static bool IsMsalCachePersistenceException(Exception e) => private AuthenticationRecord Authenticate(TokenRequestContext requestContext, CancellationToken cancellationToken) { + // If a previous attempt already proved the browser flow is unavailable in this + // environment (e.g. headless WSL / no GUI), skip straight to device code. Without + // this, the retry after RecreateCredentialsWithoutPersistence() would re-enter the + // browser path and hang waiting for an OAuth redirect that will never arrive. + if (Volatile.Read(ref _isDeviceCodeFallback) == 1) + { + _logger.LogInformation("Using device code authentication (browser flow previously unavailable)..."); + return _deviceCodeCredential.Authenticate(requestContext, cancellationToken); + } + try { _logger.LogInformation("Waiting for authentication in the browser..."); @@ -160,4 +286,32 @@ 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, + DeviceCodeCallback = (info, _) => + { + // Surface the device code through the same logger the rest of the auth flow uses; + // the default callback writes to AzureEventSource which is not visible in CLI/MCP hosts. + _logger.LogInformation("{Message}", info.Message); + return Task.CompletedTask; + }, + }); + } + + 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)); }