diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ef6c2e..9b3ff1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ The format is based on and uses the types of changes according to [Keep a Change ## [Unreleased] +### Added + +- Added support for ClientCertificate authentication [#16](https://github.com/PalmEmanuel/AzAuth/issues/16) + ## [2.2.8] - 2024-01-11 ### Added diff --git a/docs/help/Get-AzToken.md b/docs/help/Get-AzToken.md index e4dd635..cae3af2 100644 --- a/docs/help/Get-AzToken.md +++ b/docs/help/Get-AzToken.md @@ -59,6 +59,20 @@ Get-AzToken [[-Resource] ] [[-Scope] ] -TenantId [-Cl -ClientId -ClientSecret [-Force] [] ``` +### ClientCertificate +``` +Get-AzToken [[-Resource] ] [[-Scope] ] -TenantId [-Claim ] + -ClientId -ClientCertificate [-Force] + [] +``` + +### ClientCertificatePath +``` +Get-AzToken [[-Resource] ] [[-Scope] ] -TenantId [-Claim ] + -ClientId -ClientCertificatePath [-Force] + [] +``` + ## DESCRIPTION Gets a new Azure access token. @@ -123,6 +137,22 @@ PS C:\> Get-AzToken -ClientId $ClientId -ClientSecret $ClientSecret -TenantId $T Gets a new Azure access token for a client using the client credentials flow by specifying a client secret, valid for the default Microsoft Graph scope, also specifying the tenant as a mandatory parameter. +### Example 7 + +```powershell +PS C:\> Get-AzToken -ClientCertificate (Get-Item "Cert:\CurrentUser\My\$Thumbprint") -ClientId $ClientId -TenantId $TenantId +``` + +Gets a new Azure access token for a client using the client certificate flow by getting and providing an installed certificate from the user certificate store. + +### Example 8 + +```powershell +PS C:\> Get-AzToken -ClientCertificatePath ".\certAndPrivateKey.pem" -ClientId $ClientId -TenantId $TenantId +``` + +Gets a new Azure access token for a client using the client certificate flow by specifying a path to a file containing both the certificate and the private key. + ## PARAMETERS ### -Claim @@ -141,6 +171,38 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -ClientCertificate + +The certificate to be used for getting a token with the client certificate flow. + +```yaml +Type: X509Certificate2 +Parameter Sets: ClientCertificate +Aliases: + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ClientCertificatePath + +The path to a file containing both the certificate and private key, used for getting a token with the client certificate flow. + +```yaml +Type: String +Parameter Sets: ClientCertificatePath +Aliases: + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -ClientId The client id of the application used to authenticate the user or identity. If not specified the user will be authenticated with an Azure development application. @@ -159,7 +221,7 @@ Accept wildcard characters: False ```yaml Type: String -Parameter Sets: WorkloadIdentity, ClientSecret +Parameter Sets: WorkloadIdentity, ClientSecret, ClientCertificate, ClientCertificatePath Aliases: Required: True @@ -225,7 +287,7 @@ This may be required when combining interactive and non-interactive authenticati ```yaml Type: SwitchParameter -Parameter Sets: NonInteractive, Interactive, DeviceCode, ManagedIdentity, WorkloadIdentity, ClientSecret +Parameter Sets: NonInteractive, Interactive, DeviceCode, ManagedIdentity, WorkloadIdentity, ClientSecret, ClientCertificate, ClientCertificatePath Aliases: Required: False @@ -323,7 +385,7 @@ Accept wildcard characters: False ```yaml Type: String -Parameter Sets: WorkloadIdentity, ClientSecret +Parameter Sets: WorkloadIdentity, ClientSecret, ClientCertificate, ClientCertificatePath Aliases: Required: True diff --git a/source/AzAuth.Core/TokenManagerAuthMethods/TokenManager.ClientCertificate.cs b/source/AzAuth.Core/TokenManagerAuthMethods/TokenManager.ClientCertificate.cs new file mode 100644 index 0000000..26407a1 --- /dev/null +++ b/source/AzAuth.Core/TokenManagerAuthMethods/TokenManager.ClientCertificate.cs @@ -0,0 +1,64 @@ +using Azure.Core; +using Azure.Identity; +using System.Security.Cryptography.X509Certificates; + +namespace PipeHow.AzAuth; + +internal static partial class TokenManager +{ + /// + /// Gets token with a client certificate. + /// + internal static AzToken GetTokenClientCertificate(string resource, string[] scopes, string? claims, string clientId, string tenantId, X509Certificate2 clientCertificate, CancellationToken cancellationToken) => + taskFactory.Run(() => GetTokenClientCertificateAsync(resource, scopes, claims, clientId, tenantId, clientCertificate, cancellationToken)); + + /// + /// Gets token with a client certificate from a file path. + /// + internal static AzToken GetTokenClientCertificate(string resource, string[] scopes, string? claims, string clientId, string tenantId, string clientCertificatePath, CancellationToken cancellationToken) => + taskFactory.Run(() => GetTokenClientCertificateAsync(resource, scopes, claims, clientId, tenantId, clientCertificatePath, cancellationToken)); + + /// + /// Gets token with a client certificate. + /// + internal static async Task GetTokenClientCertificateAsync( + string resource, + string[] scopes, + string? claims, + string clientId, + string tenantId, + X509Certificate2 clientCertificate, + CancellationToken cancellationToken) + { + var fullScopes = scopes.Select(s => $"{resource.TrimEnd('/')}/{s}").ToArray(); + var tokenRequestContext = new TokenRequestContext(fullScopes, null, claims, tenantId); + + credential = new ClientCertificateCredential(tenantId, clientId, clientCertificate); + + previousClientId = clientId; + + return await GetTokenAsync(tokenRequestContext, cancellationToken); + } + + /// + /// Gets token with a client certificate from a file path. + /// + internal static async Task GetTokenClientCertificateAsync( + string resource, + string[] scopes, + string? claims, + string clientId, + string tenantId, + string clientCertificatePath, + CancellationToken cancellationToken) + { + var fullScopes = scopes.Select(s => $"{resource.TrimEnd('/')}/{s}").ToArray(); + var tokenRequestContext = new TokenRequestContext(fullScopes, null, claims, tenantId); + + credential = new ClientCertificateCredential(tenantId, clientId, clientCertificatePath); + + previousClientId = clientId; + + return await GetTokenAsync(tokenRequestContext, cancellationToken); + } +} \ No newline at end of file diff --git a/source/AzAuth.Core/TokenManagerAuthMethods/TokenManager.ClientSecret.cs b/source/AzAuth.Core/TokenManagerAuthMethods/TokenManager.ClientSecret.cs index 74df39c..f567604 100644 --- a/source/AzAuth.Core/TokenManagerAuthMethods/TokenManager.ClientSecret.cs +++ b/source/AzAuth.Core/TokenManagerAuthMethods/TokenManager.ClientSecret.cs @@ -8,7 +8,7 @@ internal static partial class TokenManager /// /// Gets token with a client secret. /// - internal static AzToken GetTokenClientSecret(string resource, string[] scopes, string? claims, string? clientId, string tenantId, string clientSecret, CancellationToken cancellationToken) => + internal static AzToken GetTokenClientSecret(string resource, string[] scopes, string? claims, string clientId, string tenantId, string clientSecret, CancellationToken cancellationToken) => taskFactory.Run(() => GetTokenClientSecretAsync(resource, scopes, claims, clientId, tenantId, clientSecret, cancellationToken)); /// @@ -18,7 +18,7 @@ internal static async Task GetTokenClientSecretAsync( string resource, string[] scopes, string? claims, - string? clientId, + string clientId, string tenantId, string clientSecret, CancellationToken cancellationToken) diff --git a/source/AzAuth.PS/Cmdlets/GetAzToken.cs b/source/AzAuth.PS/Cmdlets/GetAzToken.cs index a85f213..eb80785 100644 --- a/source/AzAuth.PS/Cmdlets/GetAzToken.cs +++ b/source/AzAuth.PS/Cmdlets/GetAzToken.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Management.Automation; +using System.Security.Cryptography.X509Certificates; #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. namespace PipeHow.AzAuth; @@ -14,6 +15,8 @@ public class GetAzToken : PSLoggerCmdletBase [Parameter(ParameterSetName = "ManagedIdentity", Position = 0)] [Parameter(ParameterSetName = "WorkloadIdentity", Position = 0)] [Parameter(ParameterSetName = "ClientSecret", Position = 0)] + [Parameter(ParameterSetName = "ClientCertificate", Position = 0)] + [Parameter(ParameterSetName = "ClientCertificatePath", Position = 0)] [ValidateNotNullOrEmpty] [Alias("ResourceId", "ResourceUrl")] public string Resource { get; set; } = "https://graph.microsoft.com"; @@ -25,6 +28,8 @@ public class GetAzToken : PSLoggerCmdletBase [Parameter(ParameterSetName = "ManagedIdentity", Position = 1)] [Parameter(ParameterSetName = "WorkloadIdentity", Position = 1)] [Parameter(ParameterSetName = "ClientSecret", Position = 1)] + [Parameter(ParameterSetName = "ClientCertificate", Position = 1)] + [Parameter(ParameterSetName = "ClientCertificatePath", Position = 1)] [ValidateNotNullOrEmpty] public string[] Scope { get; set; } = new[] { ".default" }; @@ -35,6 +40,8 @@ public class GetAzToken : PSLoggerCmdletBase [Parameter(ParameterSetName = "ManagedIdentity")] [Parameter(ParameterSetName = "WorkloadIdentity", Mandatory = true)] [Parameter(ParameterSetName = "ClientSecret", Mandatory = true)] + [Parameter(ParameterSetName = "ClientCertificate", Mandatory = true)] + [Parameter(ParameterSetName = "ClientCertificatePath", Mandatory = true)] [ValidateNotNullOrEmpty] public string TenantId { get; set; } @@ -45,6 +52,8 @@ public class GetAzToken : PSLoggerCmdletBase [Parameter(ParameterSetName = "ManagedIdentity")] [Parameter(ParameterSetName = "WorkloadIdentity")] [Parameter(ParameterSetName = "ClientSecret")] + [Parameter(ParameterSetName = "ClientCertificate")] + [Parameter(ParameterSetName = "ClientCertificatePath")] [ValidateNotNullOrEmpty] public string Claim { get; set; } @@ -54,6 +63,8 @@ public class GetAzToken : PSLoggerCmdletBase [Parameter(ParameterSetName = "Cache")] [Parameter(ParameterSetName = "WorkloadIdentity", Mandatory = true)] [Parameter(ParameterSetName = "ClientSecret", Mandatory = true)] + [Parameter(ParameterSetName = "ClientCertificate", Mandatory = true)] + [Parameter(ParameterSetName = "ClientCertificatePath", Mandatory = true)] [ValidateNotNullOrEmpty] public string ClientId { get; set; } @@ -93,12 +104,23 @@ public class GetAzToken : PSLoggerCmdletBase [ValidateNotNullOrEmpty] public string ClientSecret { get; set; } + [Parameter(ParameterSetName = "ClientCertificate", Mandatory = true)] + [ValidateNotNullOrEmpty] + public X509Certificate2 ClientCertificate { get; set; } + + [Parameter(ParameterSetName = "ClientCertificatePath", Mandatory = true)] + [ValidateNotNullOrEmpty] + [ValidateCertificatePath] + public string ClientCertificatePath { get; set; } + [Parameter(ParameterSetName = "NonInteractive")] [Parameter(ParameterSetName = "Interactive")] [Parameter(ParameterSetName = "DeviceCode")] [Parameter(ParameterSetName = "ManagedIdentity")] [Parameter(ParameterSetName = "WorkloadIdentity")] [Parameter(ParameterSetName = "ClientSecret")] + [Parameter(ParameterSetName = "ClientCertificate")] + [Parameter(ParameterSetName = "ClientCertificatePath")] public SwitchParameter Force { get; set; } // If user specifies Force, disregard earlier authentication @@ -181,6 +203,16 @@ Shared token cache (https://learn.microsoft.com/en-us/dotnet/api/azure.identity. WriteVerbose($"Getting token using client secret for client \"{ClientId}\" (https://learn.microsoft.com/en-us/dotnet/api/azure.identity.clientsecretcredential)."); WriteObject(TokenManager.GetTokenClientSecret(Resource, Scope, Claim, ClientId, TenantId, ClientSecret, stopProcessing.Token)); } + else if (ParameterSetName == "ClientCertificate") + { + WriteVerbose($"Getting token using client certificate for client \"{ClientId}\" (https://learn.microsoft.com/en-us/dotnet/api/azure.identity.clientcertificatecredential)."); + WriteObject(TokenManager.GetTokenClientCertificate(Resource, Scope, Claim, ClientId, TenantId, ClientCertificate, stopProcessing.Token)); + } + else if (ParameterSetName == "ClientCertificatePath") + { + WriteVerbose($"Getting token using client certificate for client \"{ClientId}\" (https://learn.microsoft.com/en-us/dotnet/api/azure.identity.clientcertificatecredential)."); + WriteObject(TokenManager.GetTokenClientCertificate(Resource, Scope, Claim, ClientId, TenantId, ClientCertificatePath, stopProcessing.Token)); + } else { throw new ArgumentException("Invalid parameter combination!"); diff --git a/source/AzAuth.PS/Cmdlets/ExistingAccounts.cs b/source/AzAuth.PS/Cmdlets/Helpers.cs similarity index 58% rename from source/AzAuth.PS/Cmdlets/ExistingAccounts.cs rename to source/AzAuth.PS/Cmdlets/Helpers.cs index fa3493d..2881479 100644 --- a/source/AzAuth.PS/Cmdlets/ExistingAccounts.cs +++ b/source/AzAuth.PS/Cmdlets/Helpers.cs @@ -1,6 +1,7 @@ using System.Collections; using System.Management.Automation; using System.Management.Automation.Language; +using System.Text.RegularExpressions; namespace PipeHow.AzAuth; @@ -24,3 +25,21 @@ public IEnumerable CompleteArgument( return results; } } + +public class ValidateCertificatePathAttribute : ValidateArgumentsAttribute +{ + protected override void Validate(object arguments, EngineIntrinsics engineIntrinsics) + { + var path = arguments as string; + + if (!Regex.Match(path, "\\.(pfx|pem)$").Success) + { + throw new ArgumentException("Only .pfx and .pem files are supported!"); + } + + if (!File.Exists(path)) + { + throw new ArgumentException($"File '{path}' does not exist."); + } + } +} \ No newline at end of file diff --git a/source/AzAuth.psd1 b/source/AzAuth.psd1 index 81e76c0..2cb75a4 100644 --- a/source/AzAuth.psd1 +++ b/source/AzAuth.psd1 @@ -4,7 +4,7 @@ RootModule = 'AzAuth.PS.dll' # Version number of this module. -ModuleVersion = '2.2.8' +ModuleVersion = '2.2.9' # Supported PSEditions CompatiblePSEditions = 'Core' diff --git a/tests/Get-AzToken.Tests.ps1 b/tests/Get-AzToken.Tests.ps1 index f1d60a2..7475a88 100644 --- a/tests/Get-AzToken.Tests.ps1 +++ b/tests/Get-AzToken.Tests.ps1 @@ -11,6 +11,8 @@ BeforeDiscovery { @{ Name = 'ManagedIdentity'; Mandatory = $false } @{ Name = 'WorkloadIdentity'; Mandatory = $false } @{ Name = 'ClientSecret'; Mandatory = $false } + @{ Name = 'ClientCertificate'; Mandatory = $false } + @{ Name = 'ClientCertificatePath'; Mandatory = $false } ) } @{ @@ -24,6 +26,8 @@ BeforeDiscovery { @{ Name = 'ManagedIdentity'; Mandatory = $false } @{ Name = 'WorkloadIdentity'; Mandatory = $false } @{ Name = 'ClientSecret'; Mandatory = $false } + @{ Name = 'ClientCertificate'; Mandatory = $false } + @{ Name = 'ClientCertificatePath'; Mandatory = $false } ) } @{ @@ -37,6 +41,8 @@ BeforeDiscovery { @{ Name = 'ManagedIdentity'; Mandatory = $false } @{ Name = 'WorkloadIdentity'; Mandatory = $true } @{ Name = 'ClientSecret'; Mandatory = $true } + @{ Name = 'ClientCertificate'; Mandatory = $true } + @{ Name = 'ClientCertificatePath'; Mandatory = $true } ) } @{ @@ -50,6 +56,8 @@ BeforeDiscovery { @{ Name = 'ManagedIdentity'; Mandatory = $false } @{ Name = 'WorkloadIdentity'; Mandatory = $false } @{ Name = 'ClientSecret'; Mandatory = $false } + @{ Name = 'ClientCertificate'; Mandatory = $false } + @{ Name = 'ClientCertificatePath'; Mandatory = $false } ) } @{ @@ -62,6 +70,8 @@ BeforeDiscovery { @{ Name = 'Cache'; Mandatory = $false } @{ Name = 'WorkloadIdentity'; Mandatory = $true } @{ Name = 'ClientSecret'; Mandatory = $true } + @{ Name = 'ClientCertificate'; Mandatory = $true } + @{ Name = 'ClientCertificatePath'; Mandatory = $true } ) } @{ @@ -130,6 +140,20 @@ BeforeDiscovery { @{ Name = 'ClientSecret'; Mandatory = $true } ) } + @{ + Name = 'ClientCertificate' + Type = 'System.Security.Cryptography.X509Certificates.X509Certificate2' + ParameterSets = @( + @{ Name = 'ClientCertificate'; Mandatory = $true } + ) + } + @{ + Name = 'ClientCertificatePath' + Type = 'string' + ParameterSets = @( + @{ Name = 'ClientCertificatePath'; Mandatory = $true } + ) + } @{ Name = 'Force' Type = 'System.Management.Automation.SwitchParameter' @@ -140,6 +164,8 @@ BeforeDiscovery { @{ Name = 'ManagedIdentity'; Mandatory = $false } @{ Name = 'WorkloadIdentity'; Mandatory = $false } @{ Name = 'ClientSecret'; Mandatory = $false } + @{ Name = 'ClientCertificate'; Mandatory = $false } + @{ Name = 'ClientCertificatePath'; Mandatory = $false } ) } )