Skip to content

Commit 41d4334

Browse files
authored
Merge branch 'main' into Daily/Release_20260527062757
2 parents 188c80a + 2710a2a commit 41d4334

10 files changed

Lines changed: 494 additions & 17 deletions

File tree

src/Accounts/Accounts/ChangeLog.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,14 @@
1919
-->
2020

2121
## Upcoming Release
22+
<<<<<<< Daily/Release_20260527062757
2223

2324
## Version 5.5.0
25+
=======
26+
* Added Service Principal support for SSH certificate generation in 'SshCredentialFactory'
27+
* Upgraded `Azure.Identity` dependency to 1.17.2.
28+
>>>>>>> main
2429
* Added ChangeSafety Support
25-
* Upgraded `Azure.Identity` to 1.17.2.
2630

2731
## Version 5.4.0
2832
* Updated the `System.Memory` dependency to v4.6.3 to support the Storage SDK update.

src/Accounts/Authentication/Authentication/TokenCache/PowerShellTokenCacheProvider.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
using System;
1616
using System.Collections.Generic;
1717
using System.Linq;
18+
using System.Security;
19+
using System.Security.Cryptography.X509Certificates;
1820

1921
using Azure.Identity;
2022

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

215217
public abstract TokenCachePersistenceOptions GetTokenCachePersistenceOptions();
216218

219+
/// <summary>
220+
/// Creates a confidential client app with a client secret.
221+
/// Used for Service Principal SSH certificate authentication.
222+
/// </summary>
223+
public virtual IConfidentialClientApplication CreateConfidentialClient(string authority, string tenantId, string clientId, string clientSecret)
224+
{
225+
var builder = ConfidentialClientApplicationBuilder.Create(clientId)
226+
.WithClientSecret(clientSecret)
227+
.WithExperimentalFeatures();
228+
if (!string.IsNullOrEmpty(authority))
229+
{
230+
builder.WithAuthority(authority, tenantId ?? organizationTenant);
231+
}
232+
return builder.Build();
233+
}
234+
235+
/// <summary>
236+
/// Creates a confidential client app with a certificate.
237+
/// Used for Service Principal SSH certificate authentication.
238+
/// </summary>
239+
public virtual IConfidentialClientApplication CreateConfidentialClient(string authority, string tenantId, string clientId, X509Certificate2 certificate)
240+
{
241+
var builder = ConfidentialClientApplicationBuilder.Create(clientId)
242+
.WithCertificate(certificate)
243+
.WithExperimentalFeatures();
244+
if (!string.IsNullOrEmpty(authority))
245+
{
246+
builder.WithAuthority(authority, tenantId ?? organizationTenant);
247+
}
248+
return builder.Build();
249+
}
250+
217251
}
218252
}

src/Accounts/Authentication/Factories/SshCredentialFactory.cs

Lines changed: 164 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,20 @@
1515
using Microsoft.Azure.Commands.Common.Authentication.Abstractions;
1616
using Microsoft.Azure.Commands.Common.Authentication.Abstractions.Models;
1717
using Microsoft.Azure.Commands.Common.Authentication.Properties;
18+
using Microsoft.Azure.Commands.ResourceManager.Common;
19+
using Microsoft.Identity.Client;
20+
using Microsoft.Identity.Client.AuthScheme;
21+
using Microsoft.Identity.Client.Extensibility;
1822
using Microsoft.Identity.Client.SSHCertificates;
1923
using Microsoft.WindowsAzure.Commands.Utilities.Common;
2024

2125
using Newtonsoft.Json;
2226

2327
using System;
2428
using System.Collections.Generic;
29+
using System.Security;
2530
using System.Security.Cryptography;
31+
using System.Security.Cryptography.X509Certificates;
2632
using System.Text;
2733

2834
namespace Microsoft.Azure.Commands.Common.Authentication.Factories
@@ -62,29 +68,184 @@ public SshCredential GetSshCredential(IAzureContext context, RSAParameters rsaKe
6268
throw new NullReferenceException(Resources.AuthenticationClientFactoryNotRegistered);
6369
}
6470

65-
var publicClient = tokenCacheProvider.CreatePublicClient(context.Environment.ActiveDirectoryAuthority, context.Tenant.Id);
6671
string scope = GetAuthScope();
6772
List<string> scopes = new List<string>() { scope };
6873
var jwk = CreateJwk(rsaKeyInfo, out string keyId);
6974

75+
switch (context.Account.Type)
76+
{
77+
case AzureAccount.AccountType.User:
78+
return AcquireTokenForUser(tokenCacheProvider, context, scopes, jwk, keyId);
79+
case AzureAccount.AccountType.ServicePrincipal:
80+
return AcquireTokenForServicePrincipal(tokenCacheProvider, context, scopes, jwk, keyId);
81+
default:
82+
throw new InvalidOperationException(string.Format(Resources.UnsupportedAccountTypeForSshCertificate, context.Account.Type));
83+
}
84+
}
85+
86+
private SshCredential AcquireTokenForUser(PowerShellTokenCacheProvider tokenCacheProvider, IAzureContext context, List<string> scopes, string jwk, string keyId)
87+
{
88+
var publicClient = tokenCacheProvider.CreatePublicClient(context.Environment.ActiveDirectoryAuthority, context.Tenant.Id);
89+
7090
var account = publicClient.GetAccountAsync(context.Account.ExtendedProperties["HomeAccountId"])
7191
.ConfigureAwait(false).GetAwaiter().GetResult();
7292
var result = publicClient.AcquireTokenSilent(scopes, account)
7393
.WithSSHCertificateAuthenticationScheme(jwk, keyId)
7494
.ExecuteAsync();
7595
var accessToken = result.ConfigureAwait(false).GetAwaiter().GetResult();
7696

77-
var resultToken = new SshCredential()
97+
return new SshCredential()
7898
{
7999
Credential = accessToken.AccessToken,
80100
ExpiresOn = accessToken.ExpiresOn,
81101
};
82-
return resultToken;
102+
}
103+
104+
private SshCredential AcquireTokenForServicePrincipal(PowerShellTokenCacheProvider tokenCacheProvider, IAzureContext context, List<string> scopes, string jwk, string keyId)
105+
{
106+
string authority = context.Environment.ActiveDirectoryAuthority;
107+
string tenantId = context.Tenant.Id;
108+
string clientId = context.Account.Id;
109+
110+
var confidentialClient = CreateConfidentialClientForServicePrincipal(tokenCacheProvider, authority, tenantId, clientId, context);
111+
112+
var authExtension = new MsalAuthenticationExtension
113+
{
114+
AuthenticationOperation = new SshCertAuthOperation(keyId, jwk)
115+
};
116+
117+
var result = confidentialClient.AcquireTokenForClient(scopes)
118+
.WithForceRefresh(true)
119+
.WithAuthenticationExtension(authExtension)
120+
.ExecuteAsync()
121+
.ConfigureAwait(false).GetAwaiter().GetResult();
122+
123+
return new SshCredential()
124+
{
125+
Credential = result.AccessToken,
126+
ExpiresOn = result.ExpiresOn,
127+
};
128+
}
129+
130+
private IConfidentialClientApplication CreateConfidentialClientForServicePrincipal(PowerShellTokenCacheProvider tokenCacheProvider, string authority, string tenantId, string clientId, IAzureContext context)
131+
{
132+
// Try certificate thumbprint first
133+
string thumbprint = context.Account.GetProperty(AzureAccount.Property.CertificateThumbprint);
134+
if (!string.IsNullOrEmpty(thumbprint))
135+
{
136+
var certificate = AzureSession.Instance.DataStore.GetCertificate(thumbprint);
137+
if (certificate != null)
138+
{
139+
return tokenCacheProvider.CreateConfidentialClient(authority, tenantId, clientId, certificate);
140+
}
141+
}
142+
143+
// Try certificate path
144+
string certificatePath = context.Account.GetProperty(AzureAccount.Property.CertificatePath);
145+
if (!string.IsNullOrEmpty(certificatePath))
146+
{
147+
SecureString certificatePassword = GetServicePrincipalSecureString(context, AzureAccount.Property.CertificatePassword);
148+
X509Certificate2 certificate = certificatePassword != null
149+
? new X509Certificate2(certificatePath, certificatePassword)
150+
: new X509Certificate2(certificatePath);
151+
return tokenCacheProvider.CreateConfidentialClient(authority, tenantId, clientId, certificate);
152+
}
153+
154+
// Try client secret
155+
string secret = context.Account.GetProperty(AzureAccount.Property.ServicePrincipalSecret);
156+
if (string.IsNullOrEmpty(secret))
157+
{
158+
SecureString secureSecret = GetServicePrincipalSecureString(context, AzureAccount.Property.ServicePrincipalSecret);
159+
if (secureSecret != null)
160+
{
161+
secret = ConvertToPlainText(secureSecret);
162+
}
163+
}
164+
165+
if (!string.IsNullOrEmpty(secret))
166+
{
167+
return tokenCacheProvider.CreateConfidentialClient(authority, tenantId, clientId, secret);
168+
}
169+
170+
throw new InvalidOperationException(Resources.ServicePrincipalCredentialNotFound);
171+
}
172+
173+
private SecureString GetServicePrincipalSecureString(IAzureContext context, string propertyName)
174+
{
175+
try
176+
{
177+
if (AzureSession.Instance.TryGetComponent(AzKeyStore.Name, out AzKeyStore keyStore))
178+
{
179+
return keyStore.GetSecureString(new ServicePrincipalKey(propertyName, context.Account.Id, context.Tenant.Id));
180+
}
181+
}
182+
catch
183+
{
184+
// Key not found in store, return null
185+
}
186+
return null;
187+
}
188+
189+
private static string ConvertToPlainText(SecureString secureString)
190+
{
191+
if (secureString == null)
192+
{
193+
return null;
194+
}
195+
var ptr = System.Runtime.InteropServices.Marshal.SecureStringToBSTR(secureString);
196+
try
197+
{
198+
return System.Runtime.InteropServices.Marshal.PtrToStringBSTR(ptr);
199+
}
200+
finally
201+
{
202+
System.Runtime.InteropServices.Marshal.ZeroFreeBSTR(ptr);
203+
}
83204
}
84205

85206
private string GetAuthScope()
86207
{
87208
return $"{AadSshLoginForLinuxServerAppId}/.default";
88209
}
210+
211+
/// <summary>
212+
/// Custom IAuthenticationOperation that instructs MSAL to request and accept
213+
/// SSH certificate token type instead of bearer tokens.
214+
/// This is the equivalent of WithSSHCertificateAuthenticationScheme for confidential client flows.
215+
/// </summary>
216+
private class SshCertAuthOperation : IAuthenticationOperation
217+
{
218+
private const string SshCertTokenType = "ssh-cert";
219+
private readonly string _jwk;
220+
221+
public SshCertAuthOperation(string keyId, string jwk)
222+
{
223+
KeyId = keyId;
224+
_jwk = jwk;
225+
}
226+
227+
public int TelemetryTokenType => 3;
228+
229+
public string AuthorizationHeaderPrefix =>
230+
throw new InvalidOperationException("SSH certificates cannot be used as HTTP authorization headers.");
231+
232+
public string AccessTokenType => SshCertTokenType;
233+
234+
public string KeyId { get; }
235+
236+
public IReadOnlyDictionary<string, string> GetTokenRequestParams()
237+
{
238+
return new Dictionary<string, string>
239+
{
240+
{ "token_type", SshCertTokenType },
241+
{ "req_cnf", _jwk }
242+
};
243+
}
244+
245+
public void FormatResult(AuthenticationResult authenticationResult)
246+
{
247+
// no-op
248+
}
249+
}
89250
}
90251
}

src/Accounts/Authentication/Properties/Resources.Designer.cs

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Accounts/Authentication/Properties/Resources.resx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,4 +434,10 @@ Run the cmdlet below to authenticate interactively; additional parameters may be
434434
Connect-AzAccount -Tenant (Get-AzContext).Tenant.Id -ClaimsChallenge "{1}"</value>
435435
<comment>0 = error message about policy violation; 1 = claims challenge in base64</comment>
436436
</data>
437+
<data name="UnsupportedAccountTypeForSshCertificate" xml:space="preserve">
438+
<value>Account type '{0}' is not supported for SSH certificate generation. Supported types are: User, ServicePrincipal.</value>
439+
</data>
440+
<data name="ServicePrincipalCredentialNotFound" xml:space="preserve">
441+
<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>
442+
</data>
437443
</root>

0 commit comments

Comments
 (0)