Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/Maestro/Maestro.Common/AppCredentials/AppCredential.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Comment thread
premun marked this conversation as resolved.

Comment thread
premun marked this conversation as resolved.
return new AppCredential(credential, requestContext);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Comment thread
lewing marked this conversation as resolved.
}
}

public override async ValueTask<AccessToken> 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);
Expand All @@ -68,10 +120,8 @@ public override AccessToken GetToken(TokenRequestContext requestContext, Cancell
}
}

public override async ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
private async ValueTask<AccessToken> GetTokenCoreAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
{
CacheAuthenticationRecord(requestContext, cancellationToken);

if (Volatile.Read(ref _isDeviceCodeFallback) == 1)
{
return await _deviceCodeCredential.GetTokenAsync(requestContext, cancellationToken);
Expand Down Expand Up @@ -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
{
Expand All @@ -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);
}

Expand All @@ -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,
});
}
Comment thread
lewing marked this conversation as resolved.

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));
}
Loading