diff --git a/src/Accounts/Accounts/ChangeLog.md b/src/Accounts/Accounts/ChangeLog.md
index 64e60047fc2c..01ba6179a063 100644
--- a/src/Accounts/Accounts/ChangeLog.md
+++ b/src/Accounts/Accounts/ChangeLog.md
@@ -19,6 +19,9 @@
-->
## Upcoming Release
+* Added Service Principal support for SSH certificate generation in 'SshCredentialFactory'
+ - Service Principals authenticated with client secret or certificate can now obtain SSH certificates for Entra ID login
+ - Enables CI/CD pipelines and automation scenarios that use Service Principal authentication with Az.SSH and Az.SFTP modules
## Version 5.4.0
* Updated the `System.Memory` dependency to v4.6.3 to support the Storage SDK update.
diff --git a/src/Accounts/Authentication/Authentication/TokenCache/PowerShellTokenCacheProvider.cs b/src/Accounts/Authentication/Authentication/TokenCache/PowerShellTokenCacheProvider.cs
index e5a81f289635..ee011e9244cd 100644
--- a/src/Accounts/Authentication/Authentication/TokenCache/PowerShellTokenCacheProvider.cs
+++ b/src/Accounts/Authentication/Authentication/TokenCache/PowerShellTokenCacheProvider.cs
@@ -15,6 +15,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Security;
+using System.Security.Cryptography.X509Certificates;
using Azure.Identity;
@@ -214,5 +216,37 @@ public virtual IPublicClientApplication CreatePublicClient(string authority = nu
public abstract TokenCachePersistenceOptions GetTokenCachePersistenceOptions();
+ ///
+ /// Creates a confidential client app with a client secret.
+ /// Used for Service Principal SSH certificate authentication.
+ ///
+ public virtual IConfidentialClientApplication CreateConfidentialClient(string authority, string tenantId, string clientId, string clientSecret)
+ {
+ var builder = ConfidentialClientApplicationBuilder.Create(clientId)
+ .WithClientSecret(clientSecret)
+ .WithExperimentalFeatures();
+ if (!string.IsNullOrEmpty(authority))
+ {
+ builder.WithAuthority(authority, tenantId ?? organizationTenant);
+ }
+ return builder.Build();
+ }
+
+ ///
+ /// Creates a confidential client app with a certificate.
+ /// Used for Service Principal SSH certificate authentication.
+ ///
+ public virtual IConfidentialClientApplication CreateConfidentialClient(string authority, string tenantId, string clientId, X509Certificate2 certificate)
+ {
+ var builder = ConfidentialClientApplicationBuilder.Create(clientId)
+ .WithCertificate(certificate)
+ .WithExperimentalFeatures();
+ if (!string.IsNullOrEmpty(authority))
+ {
+ builder.WithAuthority(authority, tenantId ?? organizationTenant);
+ }
+ return builder.Build();
+ }
+
}
}
diff --git a/src/Accounts/Authentication/Factories/SshCredentialFactory.cs b/src/Accounts/Authentication/Factories/SshCredentialFactory.cs
index 8905172e8f07..23ea36a8b138 100644
--- a/src/Accounts/Authentication/Factories/SshCredentialFactory.cs
+++ b/src/Accounts/Authentication/Factories/SshCredentialFactory.cs
@@ -15,6 +15,10 @@
using Microsoft.Azure.Commands.Common.Authentication.Abstractions;
using Microsoft.Azure.Commands.Common.Authentication.Abstractions.Models;
using Microsoft.Azure.Commands.Common.Authentication.Properties;
+using Microsoft.Azure.Commands.ResourceManager.Common;
+using Microsoft.Identity.Client;
+using Microsoft.Identity.Client.AuthScheme;
+using Microsoft.Identity.Client.Extensibility;
using Microsoft.Identity.Client.SSHCertificates;
using Microsoft.WindowsAzure.Commands.Utilities.Common;
@@ -22,7 +26,9 @@
using System;
using System.Collections.Generic;
+using System.Security;
using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
using System.Text;
namespace Microsoft.Azure.Commands.Common.Authentication.Factories
@@ -62,11 +68,25 @@ public SshCredential GetSshCredential(IAzureContext context, RSAParameters rsaKe
throw new NullReferenceException(Resources.AuthenticationClientFactoryNotRegistered);
}
- var publicClient = tokenCacheProvider.CreatePublicClient(context.Environment.ActiveDirectoryAuthority, context.Tenant.Id);
string scope = GetAuthScope();
List scopes = new List() { scope };
var jwk = CreateJwk(rsaKeyInfo, out string keyId);
+ switch (context.Account.Type)
+ {
+ case AzureAccount.AccountType.User:
+ return AcquireTokenForUser(tokenCacheProvider, context, scopes, jwk, keyId);
+ case AzureAccount.AccountType.ServicePrincipal:
+ return AcquireTokenForServicePrincipal(tokenCacheProvider, context, scopes, jwk, keyId);
+ default:
+ throw new InvalidOperationException(string.Format(Resources.UnsupportedAccountTypeForSshCertificate, context.Account.Type));
+ }
+ }
+
+ private SshCredential AcquireTokenForUser(PowerShellTokenCacheProvider tokenCacheProvider, IAzureContext context, List scopes, string jwk, string keyId)
+ {
+ var publicClient = tokenCacheProvider.CreatePublicClient(context.Environment.ActiveDirectoryAuthority, context.Tenant.Id);
+
var account = publicClient.GetAccountAsync(context.Account.ExtendedProperties["HomeAccountId"])
.ConfigureAwait(false).GetAwaiter().GetResult();
var result = publicClient.AcquireTokenSilent(scopes, account)
@@ -74,17 +94,158 @@ public SshCredential GetSshCredential(IAzureContext context, RSAParameters rsaKe
.ExecuteAsync();
var accessToken = result.ConfigureAwait(false).GetAwaiter().GetResult();
- var resultToken = new SshCredential()
+ return new SshCredential()
{
Credential = accessToken.AccessToken,
ExpiresOn = accessToken.ExpiresOn,
};
- return resultToken;
+ }
+
+ private SshCredential AcquireTokenForServicePrincipal(PowerShellTokenCacheProvider tokenCacheProvider, IAzureContext context, List scopes, string jwk, string keyId)
+ {
+ string authority = context.Environment.ActiveDirectoryAuthority;
+ string tenantId = context.Tenant.Id;
+ string clientId = context.Account.Id;
+
+ var confidentialClient = CreateConfidentialClientForServicePrincipal(tokenCacheProvider, authority, tenantId, clientId, context);
+
+ var authExtension = new MsalAuthenticationExtension
+ {
+ AuthenticationOperation = new SshCertAuthOperation(keyId, jwk)
+ };
+
+ var result = confidentialClient.AcquireTokenForClient(scopes)
+ .WithForceRefresh(true)
+ .WithAuthenticationExtension(authExtension)
+ .ExecuteAsync()
+ .ConfigureAwait(false).GetAwaiter().GetResult();
+
+ return new SshCredential()
+ {
+ Credential = result.AccessToken,
+ ExpiresOn = result.ExpiresOn,
+ };
+ }
+
+ private IConfidentialClientApplication CreateConfidentialClientForServicePrincipal(PowerShellTokenCacheProvider tokenCacheProvider, string authority, string tenantId, string clientId, IAzureContext context)
+ {
+ // Try certificate thumbprint first
+ string thumbprint = context.Account.GetProperty(AzureAccount.Property.CertificateThumbprint);
+ if (!string.IsNullOrEmpty(thumbprint))
+ {
+ var certificate = AzureSession.Instance.DataStore.GetCertificate(thumbprint);
+ if (certificate != null)
+ {
+ return tokenCacheProvider.CreateConfidentialClient(authority, tenantId, clientId, certificate);
+ }
+ }
+
+ // Try certificate path
+ string certificatePath = context.Account.GetProperty(AzureAccount.Property.CertificatePath);
+ if (!string.IsNullOrEmpty(certificatePath))
+ {
+ SecureString certificatePassword = GetServicePrincipalSecureString(context, AzureAccount.Property.CertificatePassword);
+ X509Certificate2 certificate = certificatePassword != null
+ ? new X509Certificate2(certificatePath, certificatePassword)
+ : new X509Certificate2(certificatePath);
+ return tokenCacheProvider.CreateConfidentialClient(authority, tenantId, clientId, certificate);
+ }
+
+ // Try client secret
+ string secret = context.Account.GetProperty(AzureAccount.Property.ServicePrincipalSecret);
+ if (string.IsNullOrEmpty(secret))
+ {
+ SecureString secureSecret = GetServicePrincipalSecureString(context, AzureAccount.Property.ServicePrincipalSecret);
+ if (secureSecret != null)
+ {
+ secret = ConvertToPlainText(secureSecret);
+ }
+ }
+
+ if (!string.IsNullOrEmpty(secret))
+ {
+ return tokenCacheProvider.CreateConfidentialClient(authority, tenantId, clientId, secret);
+ }
+
+ throw new InvalidOperationException(Resources.ServicePrincipalCredentialNotFound);
+ }
+
+ private SecureString GetServicePrincipalSecureString(IAzureContext context, string propertyName)
+ {
+ try
+ {
+ if (AzureSession.Instance.TryGetComponent(AzKeyStore.Name, out AzKeyStore keyStore))
+ {
+ return keyStore.GetSecureString(new ServicePrincipalKey(propertyName, context.Account.Id, context.Tenant.Id));
+ }
+ }
+ catch
+ {
+ // Key not found in store, return null
+ }
+ return null;
+ }
+
+ private static string ConvertToPlainText(SecureString secureString)
+ {
+ if (secureString == null)
+ {
+ return null;
+ }
+ var ptr = System.Runtime.InteropServices.Marshal.SecureStringToBSTR(secureString);
+ try
+ {
+ return System.Runtime.InteropServices.Marshal.PtrToStringBSTR(ptr);
+ }
+ finally
+ {
+ System.Runtime.InteropServices.Marshal.ZeroFreeBSTR(ptr);
+ }
}
private string GetAuthScope()
{
return $"{AadSshLoginForLinuxServerAppId}/.default";
}
+
+ ///
+ /// Custom IAuthenticationOperation that instructs MSAL to request and accept
+ /// SSH certificate token type instead of bearer tokens.
+ /// This is the equivalent of WithSSHCertificateAuthenticationScheme for confidential client flows.
+ ///
+ private class SshCertAuthOperation : IAuthenticationOperation
+ {
+ private const string SshCertTokenType = "ssh-cert";
+ private readonly string _jwk;
+
+ public SshCertAuthOperation(string keyId, string jwk)
+ {
+ KeyId = keyId;
+ _jwk = jwk;
+ }
+
+ public int TelemetryTokenType => 3;
+
+ public string AuthorizationHeaderPrefix =>
+ throw new InvalidOperationException("SSH certificates cannot be used as HTTP authorization headers.");
+
+ public string AccessTokenType => SshCertTokenType;
+
+ public string KeyId { get; }
+
+ public IReadOnlyDictionary GetTokenRequestParams()
+ {
+ return new Dictionary
+ {
+ { "token_type", SshCertTokenType },
+ { "req_cnf", _jwk }
+ };
+ }
+
+ public void FormatResult(AuthenticationResult authenticationResult)
+ {
+ // no-op
+ }
+ }
}
}
diff --git a/src/Accounts/Authentication/Properties/Resources.Designer.cs b/src/Accounts/Authentication/Properties/Resources.Designer.cs
index 53b43fdb5a3c..de15293c7c8b 100644
--- a/src/Accounts/Authentication/Properties/Resources.Designer.cs
+++ b/src/Accounts/Authentication/Properties/Resources.Designer.cs
@@ -963,5 +963,23 @@ public static string x86InProgramFiles {
return ResourceManager.GetString("x86InProgramFiles", resourceCulture);
}
}
+
+ ///
+ /// Looks up a localized string similar to Account type '{0}' is not supported for SSH certificate generation. Supported types are: User, ServicePrincipal..
+ ///
+ public static string UnsupportedAccountTypeForSshCertificate {
+ get {
+ return ResourceManager.GetString("UnsupportedAccountTypeForSshCertificate", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to No credential (client secret or certificate) found for the Service Principal. Please re-authenticate using Connect-AzAccount with -CertificateThumbprint, -CertificatePath, or a client secret..
+ ///
+ public static string ServicePrincipalCredentialNotFound {
+ get {
+ return ResourceManager.GetString("ServicePrincipalCredentialNotFound", resourceCulture);
+ }
+ }
}
}
diff --git a/src/Accounts/Authentication/Properties/Resources.resx b/src/Accounts/Authentication/Properties/Resources.resx
index 2d06378d1b8d..791aec92b5df 100644
--- a/src/Accounts/Authentication/Properties/Resources.resx
+++ b/src/Accounts/Authentication/Properties/Resources.resx
@@ -434,4 +434,10 @@ Run the cmdlet below to authenticate interactively; additional parameters may be
Connect-AzAccount -Tenant (Get-AzContext).Tenant.Id -ClaimsChallenge "{1}"
0 = error message about policy violation; 1 = claims challenge in base64
+
+ Account type '{0}' is not supported for SSH certificate generation. Supported types are: User, ServicePrincipal.
+
+
+ No credential (client secret or certificate) found for the Service Principal. Please re-authenticate using Connect-AzAccount with -CertificateThumbprint, -CertificatePath, or a client secret.
+
\ No newline at end of file
diff --git a/src/Sftp/Sftp.Test/ScenarioTests/FileUtilsTests.cs b/src/Sftp/Sftp.Test/ScenarioTests/FileUtilsTests.cs
index e1deab50a0c5..444ebc1fdf61 100644
--- a/src/Sftp/Sftp.Test/ScenarioTests/FileUtilsTests.cs
+++ b/src/Sftp/Sftp.Test/ScenarioTests/FileUtilsTests.cs
@@ -1,8 +1,11 @@
using System;
using System.IO;
+using System.Security.Cryptography;
using System.Threading.Tasks;
using Microsoft.Azure.Commands.Common.Authentication;
using Microsoft.Azure.Commands.Common.Authentication.Abstractions;
+using Microsoft.Azure.Commands.Common.Authentication.Abstractions.Models;
+using Microsoft.Azure.Commands.Common.Authentication.Models;
using Microsoft.Azure.Commands.Common.Exceptions;
using Microsoft.Azure.PowerShell.Cmdlets.Sftp.Common;
using Xunit;
@@ -15,7 +18,7 @@ namespace Microsoft.Azure.Commands.Sftp.Test.ScenarioTests
/// Port of Azure CLI test_file_utils.py
/// Owner: johnli1
///
- public class FileUtilsTests
+ public class FileUtilsTests : IDisposable
{
private string _tempDir;
@@ -23,6 +26,37 @@ public FileUtilsTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), "sftp_file_utils_test_" + Guid.NewGuid().ToString("N").Substring(0, 8));
Directory.CreateDirectory(_tempDir);
+ EnsureAzureSessionInitialized();
+ }
+
+ private static bool _sessionInitialized = false;
+ private static readonly object _sessionLock = new object();
+
+ private static void EnsureAzureSessionInitialized()
+ {
+ lock (_sessionLock)
+ {
+ if (!_sessionInitialized)
+ {
+ var dataStore = new MemoryDataStore();
+ var session = new AzureSessionInitializer.AdalSession
+ {
+ DataStore = dataStore,
+ ProfileDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".Azure"),
+ ProfileFile = "AzureProfile.json",
+ TokenCacheDirectory = Path.GetTempPath(),
+ TokenCacheFile = "msal.cache"
+ };
+ session.TokenCache = session.TokenCache ?? new AzureTokenCache();
+ AzureSession.Initialize(() => session, true);
+ _sessionInitialized = true;
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ TearDown();
}
private void TearDown()
@@ -594,5 +628,232 @@ public void TestGetAndWriteCertificateUnsupportedCloud()
TearDown();
}
}
+
+ ///
+ /// Generates a valid SSH RSA public key file for testing.
+ ///
+ private string CreateTestPublicKeyFile()
+ {
+ using (var rsa = RSA.Create(2048))
+ {
+ var parameters = rsa.ExportParameters(false);
+ // Build SSH public key format: ssh-rsa
+ using (var ms = new System.IO.MemoryStream())
+ using (var writer = new System.IO.BinaryWriter(ms))
+ {
+ var algorithmBytes = System.Text.Encoding.ASCII.GetBytes("ssh-rsa");
+ WriteSshField(writer, algorithmBytes);
+ WriteSshField(writer, parameters.Exponent);
+ WriteSshField(writer, parameters.Modulus);
+ var base64 = Convert.ToBase64String(ms.ToArray());
+ var publicKeyText = $"ssh-rsa {base64} test@test";
+ var publicKeyFile = Path.Combine(_tempDir, "id_rsa.pub");
+ File.WriteAllText(publicKeyFile, publicKeyText);
+ return publicKeyFile;
+ }
+ }
+ }
+
+ private static void WriteSshField(System.IO.BinaryWriter writer, byte[] data)
+ {
+ // SSH uses big-endian 4-byte length prefix
+ var lengthBytes = BitConverter.GetBytes(data.Length);
+ if (BitConverter.IsLittleEndian)
+ {
+ Array.Reverse(lengthBytes);
+ }
+ writer.Write(lengthBytes);
+ writer.Write(data);
+ }
+
+ ///
+ /// Sets up AzureSession with a mock ISshCredentialFactory that returns a credential
+ /// with the given token string.
+ ///
+ private void SetupMockSshCredentialFactory(string credentialToken)
+ {
+ var mockCredential = new SshCredential()
+ {
+ Credential = credentialToken,
+ ExpiresOn = DateTimeOffset.UtcNow.AddHours(1),
+ };
+
+ var mockFactory = new Mock();
+ mockFactory.Setup(f => f.GetSshCredential(
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(mockCredential);
+
+ AzureSession.Instance.RegisterComponent(
+ nameof(ISshCredentialFactory), () => mockFactory.Object, true);
+ }
+
+ private IAzureContext CreateMockContext(string accountType)
+ {
+ var contextMock = new Mock();
+ var envMock = new Mock();
+ envMock.Setup(e => e.Name).Returns("AzureCloud");
+ envMock.Setup(e => e.ActiveDirectoryAuthority).Returns("https://login.microsoftonline.com/");
+ contextMock.Setup(c => c.Environment).Returns(envMock.Object);
+
+ var tenantMock = new Mock();
+ tenantMock.Setup(t => t.Id).Returns("00000000-0000-0000-0000-000000000001");
+ contextMock.Setup(c => c.Tenant).Returns(tenantMock.Object);
+
+ var accountMock = new Mock();
+ accountMock.Setup(a => a.Type).Returns(accountType);
+ accountMock.Setup(a => a.Id).Returns("test-app-id");
+ contextMock.Setup(c => c.Account).Returns(accountMock.Object);
+
+ return contextMock.Object;
+ }
+
+ [Fact]
+ public void TestGetAndWriteCertificateServicePrincipalCallsFactory()
+ {
+ try
+ {
+ // Arrange
+ var publicKeyFile = CreateTestPublicKeyFile();
+ var certFile = Path.Combine(_tempDir, "id_rsa-cert.pub");
+ var dummyToken = "AAAAB3NzaC1yc2EAAAADAQAB_test_sp_token";
+
+ SetupMockSshCredentialFactory(dummyToken);
+ var context = CreateMockContext(AzureAccount.AccountType.ServicePrincipal);
+
+ // Act - GetAndWriteCertificate will call factory.GetSshCredential, write cert,
+ // then try to extract principals via ssh-keygen (which won't be available in test).
+ // We expect it to either succeed or throw at principal extraction stage.
+ Exception caughtException = null;
+ try
+ {
+ FileUtils.GetAndWriteCertificate(context, publicKeyFile, certFile, null);
+ }
+ catch (Exception ex)
+ {
+ caughtException = ex;
+ }
+
+ // Assert - The cert file should have been written (factory was called successfully)
+ Assert.True(File.Exists(certFile), "Certificate file should have been written by the factory");
+ var certContent = File.ReadAllText(certFile);
+ Assert.Contains(dummyToken, certContent);
+ Assert.StartsWith("ssh-rsa-cert-v01@openssh.com", certContent);
+ }
+ finally
+ {
+ TearDown();
+ }
+ }
+
+ [Fact]
+ public void TestGetAndWriteCertificateUserAccountCallsFactory()
+ {
+ try
+ {
+ // Arrange
+ var publicKeyFile = CreateTestPublicKeyFile();
+ var certFile = Path.Combine(_tempDir, "id_rsa-cert.pub");
+ var dummyToken = "AAAAB3NzaC1yc2EAAAADAQAB_test_user_token";
+
+ SetupMockSshCredentialFactory(dummyToken);
+ var context = CreateMockContext(AzureAccount.AccountType.User);
+
+ // Act
+ Exception caughtException = null;
+ try
+ {
+ FileUtils.GetAndWriteCertificate(context, publicKeyFile, certFile, null);
+ }
+ catch (Exception ex)
+ {
+ caughtException = ex;
+ }
+
+ // Assert - The cert file should have been written
+ Assert.True(File.Exists(certFile), "Certificate file should have been written by the factory");
+ var certContent = File.ReadAllText(certFile);
+ Assert.Contains(dummyToken, certContent);
+ Assert.StartsWith("ssh-rsa-cert-v01@openssh.com", certContent);
+ }
+ finally
+ {
+ TearDown();
+ }
+ }
+
+ [Fact]
+ public void TestGetAndWriteCertificateFactoryNotRegisteredThrows()
+ {
+ try
+ {
+ // Arrange - ensure no factory is registered by registering a null-returning component
+ var publicKeyFile = CreateTestPublicKeyFile();
+ var certFile = Path.Combine(_tempDir, "id_rsa-cert.pub");
+ var context = CreateMockContext(AzureAccount.AccountType.ServicePrincipal);
+
+ // Act & Assert - Without a factory registered, this should throw
+ Assert.ThrowsAny(() =>
+ FileUtils.GetAndWriteCertificate(context, publicKeyFile, certFile, null));
+ }
+ finally
+ {
+ TearDown();
+ }
+ }
+
+ [Fact]
+ public void TestGetAndWriteCertificateNullCredentialThrows()
+ {
+ try
+ {
+ // Arrange - factory returns null
+ var mockFactory = new Mock();
+ mockFactory.Setup(f => f.GetSshCredential(
+ It.IsAny(),
+ It.IsAny()))
+ .Returns((SshCredential)null);
+
+ AzureSession.Instance.RegisterComponent(
+ nameof(ISshCredentialFactory), () => mockFactory.Object, true);
+
+ var publicKeyFile = CreateTestPublicKeyFile();
+ var certFile = Path.Combine(_tempDir, "id_rsa-cert.pub");
+ var context = CreateMockContext(AzureAccount.AccountType.ServicePrincipal);
+
+ // Act & Assert
+ var ex = Assert.Throws(() =>
+ FileUtils.GetAndWriteCertificate(context, publicKeyFile, certFile, null));
+ Assert.Contains("Failed to obtain SSH certificate credential", ex.Message);
+ }
+ finally
+ {
+ TearDown();
+ }
+ }
+
+ [Fact]
+ public void TestGetAndWriteCertificateEmptyCredentialStringThrows()
+ {
+ try
+ {
+ // Arrange - factory returns credential with empty string
+ SetupMockSshCredentialFactory(string.Empty);
+
+ var publicKeyFile = CreateTestPublicKeyFile();
+ var certFile = Path.Combine(_tempDir, "id_rsa-cert.pub");
+ var context = CreateMockContext(AzureAccount.AccountType.ServicePrincipal);
+
+ // Act & Assert - The inner AzPSInvalidOperationException gets caught and re-wrapped
+ // by the outer catch block as AzPSApplicationException
+ var ex = Assert.Throws(() =>
+ FileUtils.GetAndWriteCertificate(context, publicKeyFile, certFile, null));
+ Assert.Contains("SSH credential string is null or empty", ex.Message);
+ }
+ finally
+ {
+ TearDown();
+ }
+ }
}
}
diff --git a/src/Sftp/Sftp/CHANGELOG.md b/src/Sftp/Sftp/CHANGELOG.md
index fae847bea162..53d0d19db6f9 100644
--- a/src/Sftp/Sftp/CHANGELOG.md
+++ b/src/Sftp/Sftp/CHANGELOG.md
@@ -19,6 +19,9 @@
-->
## Upcoming Release
+* Added Service Principal support for SFTP with Entra ID (AAD) login
+ - Users authenticated as Service Principals can now use SFTP commands with Entra ID certificate authentication
+ - Removed restriction that limited SSH certificate generation to User accounts only
## Version 0.2.0
* Added confirmation prompt when an SSH key pair already exists at the target location
diff --git a/src/Sftp/Sftp/Common/FileUtils.cs b/src/Sftp/Sftp/Common/FileUtils.cs
index 9b4524f8b1cc..7d6e94084e50 100644
--- a/src/Sftp/Sftp/Common/FileUtils.cs
+++ b/src/Sftp/Sftp/Common/FileUtils.cs
@@ -311,13 +311,9 @@ public static Tuple GetAndWriteCertificate(IAzureContext context
"Authentication failed. User interaction is required. " +
"This may be due to conditional access policy settings such as multi-factor authentication (MFA). ", ex);
}
- catch (System.Collections.Generic.KeyNotFoundException exception)
+ catch (Exception ex2) when (ex2 is System.Collections.Generic.KeyNotFoundException || ex2 is InvalidOperationException)
{
- if (context.Account.Type != AzureAccount.AccountType.User)
- {
- throw new AzPSApplicationException($"Failed to generate AAD certificate. Unsupported account type: {context.Account.Type}. Only User accounts are supported for SSH certificate generation.");
- }
- throw new AzPSApplicationException($"Failed to generate AAD certificate: {exception.Message}. Please ensure you are properly authenticated with 'Connect-AzAccount'.");
+ throw new AzPSApplicationException($"Failed to generate AAD certificate: {ex2.Message}. Please ensure you are properly authenticated with 'Connect-AzAccount'.");
}
// Write OpenSSH certificate
diff --git a/src/Ssh/Ssh/ChangeLog.md b/src/Ssh/Ssh/ChangeLog.md
index 15313a9789aa..734a60a3bad9 100644
--- a/src/Ssh/Ssh/ChangeLog.md
+++ b/src/Ssh/Ssh/ChangeLog.md
@@ -19,6 +19,9 @@
-->
## Upcoming Release
+* Added Service Principal support for SSH with Entra ID (AAD) login
+ - Users authenticated as Service Principals can now use SSH commands with Entra ID certificate authentication
+ - Removed restriction that limited SSH certificate generation to User accounts only
## Version 0.2.3
* Implemented code refactoring, no behavior changes expected.
diff --git a/src/Ssh/Ssh/Common/SshBaseCmdlet.cs b/src/Ssh/Ssh/Common/SshBaseCmdlet.cs
index 453e13096380..e1bb52e4eea0 100644
--- a/src/Ssh/Ssh/Common/SshBaseCmdlet.cs
+++ b/src/Ssh/Ssh/Common/SshBaseCmdlet.cs
@@ -1030,13 +1030,8 @@ private SshCredential GetAccessToken(string publicKeyFile)
{
token = factory.GetSshCredential(context, parameters);
}
- catch (KeyNotFoundException exception)
+ catch (Exception exception) when (exception is KeyNotFoundException || exception is InvalidOperationException)
{
- if (context.Account.Type != AzureAccount.AccountType.User)
- {
- throw new AzPSApplicationException(String.Format(Resources.FailedToAADUnsupportedAccountType, context.Account.Type));
- }
-
throw new AzPSApplicationException($"Failed to generate AAD certificate with exception: {exception.Message}.");
}