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
3 changes: 3 additions & 0 deletions src/Accounts/Accounts/ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
DevanshG1 marked this conversation as resolved.

## Version 5.4.0
* Updated the `System.Memory` dependency to v4.6.3 to support the Storage SDK update.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security;
using System.Security.Cryptography.X509Certificates;

using Azure.Identity;

Expand Down Expand Up @@ -214,5 +216,37 @@ public virtual IPublicClientApplication CreatePublicClient(string authority = nu

public abstract TokenCachePersistenceOptions GetTokenCachePersistenceOptions();

/// <summary>
/// Creates a confidential client app with a client secret.
/// Used for Service Principal SSH certificate authentication.
/// </summary>
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();
}

/// <summary>
/// Creates a confidential client app with a certificate.
/// Used for Service Principal SSH certificate authentication.
/// </summary>
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();
}

}
}
167 changes: 164 additions & 3 deletions src/Accounts/Authentication/Factories/SshCredentialFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,20 @@
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;

using Newtonsoft.Json;

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
Expand Down Expand Up @@ -62,29 +68,184 @@ 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<string> scopes = new List<string>() { 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<string> 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)
.WithSSHCertificateAuthenticationScheme(jwk, keyId)
.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<string> 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)
Comment thread
DevanshG1 marked this conversation as resolved.
.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);
Comment on lines +137 to +151
}

// 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";
}

/// <summary>
/// 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.
/// </summary>
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<string, string> GetTokenRequestParams()
{
return new Dictionary<string, string>
{
{ "token_type", SshCertTokenType },
{ "req_cnf", _jwk }
};
}

public void FormatResult(AuthenticationResult authenticationResult)
{
// no-op
}
}
}
}
18 changes: 18 additions & 0 deletions src/Accounts/Authentication/Properties/Resources.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions src/Accounts/Authentication/Properties/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -434,4 +434,10 @@ Run the cmdlet below to authenticate interactively; additional parameters may be
Connect-AzAccount -Tenant (Get-AzContext).Tenant.Id -ClaimsChallenge "{1}"</value>
<comment>0 = error message about policy violation; 1 = claims challenge in base64</comment>
</data>
<data name="UnsupportedAccountTypeForSshCertificate" xml:space="preserve">
<value>Account type '{0}' is not supported for SSH certificate generation. Supported types are: User, ServicePrincipal.</value>
</data>
<data name="ServicePrincipalCredentialNotFound" xml:space="preserve">
<value>No credential (client secret or certificate) found for the Service Principal. Please re-authenticate using Connect-AzAccount with -CertificateThumbprint, -CertificatePath, or a client secret.</value>
</data>
</root>
Loading
Loading