diff --git a/CHANGELOG.md b/CHANGELOG.md index 2902d67..247b9a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ The format is based on and uses the types of changes according to [Keep a Change ## [Unreleased] +### Added + +- Added `AzureDeveloperCli` to the `CredentialPrecedence` parameter for non-interactive authentication, allowing use of Azure Developer CLI (`azd`) credentials +- Added `-AzurePipelines` parameter set for Azure Pipelines service connection OIDC authentication (`AzurePipelinesCredential`) + +### Removed + +- Removed deprecated `SharedTokenCache` from the `CredentialPrecedence` parameter and default credential chain, as `SharedTokenCacheCredential` is deprecated in Azure.Identity + ## [2.8.0] - 2026-03-05 - Improve error handling by replacing exceptions with WriteError diff --git a/docs/help/Get-AzToken.md b/docs/help/Get-AzToken.md index 3e562e0..6e24d78 100644 --- a/docs/help/Get-AzToken.md +++ b/docs/help/Get-AzToken.md @@ -78,6 +78,12 @@ Get-AzToken [[-Resource] ] [[-Scope] ] -Tenant [-Clai -ClientCertificatePath [-Force] [] ``` +### AzurePipelines +``` +Get-AzToken [[-Resource] ] [[-Scope] ] -Tenant [-Claim ] -ClientId + [-AzurePipelines] -ServiceConnectionId -SystemAccessToken [-Force] [] +``` + ## DESCRIPTION Gets a new Azure access token. @@ -88,9 +94,9 @@ The token can be retrieved from an existing named cache, interactively from a br - Environment variables (https://learn.microsoft.com/en-us/dotnet/api/azure.identity.environmentcredential) - Azure PowerShell (https://learn.microsoft.com/en-us/dotnet/api/azure.identity.azurepowershellcredential) - Azure CLI (https://learn.microsoft.com/en-us/dotnet/api/azure.identity.azureclicredential) -- Visual Studio Code (https://learn.microsoft.com/en-us/dotnet/api/azure.identity.visualstudiocodecredential) +- Azure Developer CLI (https://learn.microsoft.com/en-us/dotnet/api/azure.identity.azuredeveloperclicredential) - Visual Studio (https://learn.microsoft.com/en-us/dotnet/api/azure.identity.visualstudiocredential) -- Shared token cache (https://learn.microsoft.com/en-us/dotnet/api/azure.identity.sharedtokencachecredential) +- Managed identity (https://learn.microsoft.com/en-us/dotnet/api/azure.identity.managedidentitycredential) ## EXAMPLES @@ -258,7 +264,7 @@ Accept wildcard characters: False ```yaml Type: String -Parameter Sets: WorkloadIdentity, ClientSecret, ClientCertificate, ClientCertificatePath +Parameter Sets: WorkloadIdentity, ClientSecret, ClientCertificate, ClientCertificatePath, AzurePipelines Aliases: Required: True @@ -292,7 +298,7 @@ The order of precedence for the credentials to be used for getting a token non-i Type: String[] Parameter Sets: NonInteractive Aliases: -Accepted values: ManagedIdentity, Environment, AzurePowerShell, AzureCLI, VisualStudio, SharedTokenCache +Accepted values: ManagedIdentity, Environment, AzurePowerShell, AzureCLI, AzureDeveloperCli, VisualStudio Required: False Position: Named @@ -341,7 +347,7 @@ This may be required when combining interactive and non-interactive authenticati ```yaml Type: SwitchParameter -Parameter Sets: NonInteractive, Interactive, DeviceCode, ManagedIdentity, WorkloadIdentity, ClientSecret, ClientCertificate, ClientCertificatePath +Parameter Sets: NonInteractive, Interactive, DeviceCode, ManagedIdentity, WorkloadIdentity, ClientSecret, ClientCertificate, ClientCertificatePath, AzurePipelines Aliases: Required: False @@ -438,7 +444,7 @@ Accept wildcard characters: False ```yaml Type: String -Parameter Sets: WorkloadIdentity, ClientSecret, ClientCertificate, ClientCertificatePath +Parameter Sets: WorkloadIdentity, ClientSecret, ClientCertificate, ClientCertificatePath, AzurePipelines Aliases: TenantId Required: True @@ -540,6 +546,54 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -AzurePipelines + +Get a token using an Azure Pipelines service connection with OIDC (OpenID Connect). + +```yaml +Type: SwitchParameter +Parameter Sets: AzurePipelines +Aliases: + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ServiceConnectionId + +The service connection id configured in Azure Pipelines, used together with -AzurePipelines. + +```yaml +Type: String +Parameter Sets: AzurePipelines +Aliases: + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -SystemAccessToken + +The pipeline system access token (`$(System.AccessToken)` or `$env:SYSTEM_ACCESSTOKEN`), used together with -AzurePipelines. + +```yaml +Type: String +Parameter Sets: AzurePipelines +Aliases: + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, -ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). diff --git a/source/AzAuth.Core/TokenManager.cs b/source/AzAuth.Core/TokenManager.cs index 5b3cca9..8f1793d 100644 --- a/source/AzAuth.Core/TokenManager.cs +++ b/source/AzAuth.Core/TokenManager.cs @@ -21,8 +21,8 @@ internal static string GetCredentialDocumentationUrl(string credentialType) "Environment" => "https://learn.microsoft.com/en-us/dotnet/api/azure.identity.environmentcredential", "AzurePowerShell" => "https://learn.microsoft.com/en-us/dotnet/api/azure.identity.azurepowershellcredential", "AzureCLI" => "https://learn.microsoft.com/en-us/dotnet/api/azure.identity.azureclicredential", + "AzureDeveloperCli" => "https://learn.microsoft.com/en-us/dotnet/api/azure.identity.azuredeveloperclicredential", "VisualStudio" => "https://learn.microsoft.com/en-us/dotnet/api/azure.identity.visualstudiocredential", - "SharedTokenCache" => "https://learn.microsoft.com/en-us/dotnet/api/azure.identity.sharedtokencachecredential", _ => throw new ArgumentException("Invalid credential type", nameof(credentialType)) }; } diff --git a/source/AzAuth.Core/TokenManagerAuthMethods/TokenManager.AzurePipelines.cs b/source/AzAuth.Core/TokenManagerAuthMethods/TokenManager.AzurePipelines.cs new file mode 100644 index 0000000..4f2e564 --- /dev/null +++ b/source/AzAuth.Core/TokenManagerAuthMethods/TokenManager.AzurePipelines.cs @@ -0,0 +1,40 @@ +using Azure.Core; +using Azure.Identity; + +namespace PipeHow.AzAuth; + +internal static partial class TokenManager +{ + /// + /// Gets token using Azure Pipelines service connection OIDC. + /// + internal static AzToken GetTokenAzurePipelines(string resource, string[] scopes, string? claims, string clientId, string tenantId, string serviceConnectionId, string systemAccessToken, CancellationToken cancellationToken) => + taskFactory.Run(() => GetTokenAzurePipelinesAsync(resource, scopes, claims, clientId, tenantId, serviceConnectionId, systemAccessToken, cancellationToken)); + + /// + /// Gets token using Azure Pipelines service connection OIDC. + /// + internal static async Task GetTokenAzurePipelinesAsync( + string resource, + string[] scopes, + string? claims, + string clientId, + string tenantId, + string serviceConnectionId, + string systemAccessToken, + CancellationToken cancellationToken) + { + var fullScopes = scopes.Select(s => $"{resource.TrimEnd('/')}/{s}").ToArray(); + var tokenRequestContext = new TokenRequestContext(fullScopes, null, claims, tenantId); + + // Re-use the previous credential if client id didn't change + if (credential is not AzurePipelinesCredential || previousClientId != clientId) + { + credential = new AzurePipelinesCredential(tenantId, clientId, serviceConnectionId, systemAccessToken); + } + + previousClientId = clientId; + + return await GetTokenAsync(tokenRequestContext, cancellationToken); + } +} diff --git a/source/AzAuth.Core/TokenManagerAuthMethods/TokenManager.NonInteractive.cs b/source/AzAuth.Core/TokenManagerAuthMethods/TokenManager.NonInteractive.cs index d3de57a..7a91439 100644 --- a/source/AzAuth.Core/TokenManagerAuthMethods/TokenManager.NonInteractive.cs +++ b/source/AzAuth.Core/TokenManagerAuthMethods/TokenManager.NonInteractive.cs @@ -58,8 +58,8 @@ internal static async Task GetTokenNonInteractiveAsync( "Environment" => new EnvironmentCredential(genericTimeoutOptions), "AzurePowerShell" => new AzurePowerShellCredential(genericTimeoutOptions as AzurePowerShellCredentialOptions), "AzureCLI" => new AzureCliCredential(genericTimeoutOptions as AzureCliCredentialOptions), + "AzureDeveloperCli" => new AzureDeveloperCliCredential(genericTimeoutOptions as AzureDeveloperCliCredentialOptions), "VisualStudio" => new VisualStudioCredential(genericTimeoutOptions as VisualStudioCredentialOptions), - "SharedTokenCache" => new SharedTokenCacheCredential(genericTimeoutOptions as SharedTokenCacheCredentialOptions), _ => throw new ArgumentException("Invalid credential type", nameof(credentialType)) }); } diff --git a/source/AzAuth.PS/Cmdlets/GetAzToken.cs b/source/AzAuth.PS/Cmdlets/GetAzToken.cs index b77708f..92d39ae 100644 --- a/source/AzAuth.PS/Cmdlets/GetAzToken.cs +++ b/source/AzAuth.PS/Cmdlets/GetAzToken.cs @@ -19,6 +19,7 @@ public class GetAzToken : PSLoggerCmdletBase [Parameter(ParameterSetName = "ClientSecret", Position = 0)] [Parameter(ParameterSetName = "ClientCertificate", Position = 0)] [Parameter(ParameterSetName = "ClientCertificatePath", Position = 0)] + [Parameter(ParameterSetName = "AzurePipelines", Position = 0)] [ValidateNotNullOrEmpty] [Alias("ResourceId", "ResourceUrl")] public string Resource { get; set; } = "https://graph.microsoft.com"; @@ -33,6 +34,7 @@ public class GetAzToken : PSLoggerCmdletBase [Parameter(ParameterSetName = "ClientSecret", Position = 1)] [Parameter(ParameterSetName = "ClientCertificate", Position = 1)] [Parameter(ParameterSetName = "ClientCertificatePath", Position = 1)] + [Parameter(ParameterSetName = "AzurePipelines", Position = 1)] [ValidateNotNullOrEmpty] public string[] Scope { get; set; } = new[] { ".default" }; @@ -46,6 +48,7 @@ public class GetAzToken : PSLoggerCmdletBase [Parameter(ParameterSetName = "ClientSecret", Mandatory = true)] [Parameter(ParameterSetName = "ClientCertificate", Mandatory = true)] [Parameter(ParameterSetName = "ClientCertificatePath", Mandatory = true)] + [Parameter(ParameterSetName = "AzurePipelines", Mandatory = true)] [ValidateNotNullOrEmpty] [Alias("TenantId")] public string Tenant { get; set; } @@ -60,6 +63,7 @@ public class GetAzToken : PSLoggerCmdletBase [Parameter(ParameterSetName = "ClientSecret")] [Parameter(ParameterSetName = "ClientCertificate")] [Parameter(ParameterSetName = "ClientCertificatePath")] + [Parameter(ParameterSetName = "AzurePipelines")] [ValidateNotNullOrEmpty] public string Claim { get; set; } @@ -73,6 +77,7 @@ public class GetAzToken : PSLoggerCmdletBase [Parameter(ParameterSetName = "ClientSecret", Mandatory = true)] [Parameter(ParameterSetName = "ClientCertificate", Mandatory = true)] [Parameter(ParameterSetName = "ClientCertificatePath", Mandatory = true)] + [Parameter(ParameterSetName = "AzurePipelines", Mandatory = true)] [ValidateNotNullOrEmpty] public string ClientId { get; set; } @@ -101,10 +106,10 @@ public class GetAzToken : PSLoggerCmdletBase public int TimeoutSeconds { get; set; } = 120; [Parameter(ParameterSetName = "NonInteractive")] - [ValidateSet("ManagedIdentity", "Environment", "AzurePowerShell", "AzureCLI", "VisualStudio", "SharedTokenCache")] + [ValidateSet("ManagedIdentity", "Environment", "AzurePowerShell", "AzureCLI", "AzureDeveloperCli", "VisualStudio")] [ValidateNotNullOrEmpty()] // TODO: Change back ManagedIdentity to first position in the chain once issue #112 is solved, likely in Azure.Identity 1.14.2 or later - public string[] CredentialPrecedence { get; set; } = ["Environment", "AzurePowerShell", "AzureCLI", "VisualStudio", "SharedTokenCache", "ManagedIdentity"]; + public string[] CredentialPrecedence { get; set; } = ["Environment", "AzurePowerShell", "AzureCLI", "AzureDeveloperCli", "VisualStudio", "ManagedIdentity"]; [Parameter(ParameterSetName = "Interactive", Mandatory = true)] public SwitchParameter Interactive { get; set; } @@ -125,6 +130,17 @@ public class GetAzToken : PSLoggerCmdletBase [ValidateNotNullOrEmpty] public string ExternalToken { get; set; } + [Parameter(ParameterSetName = "AzurePipelines", Mandatory = true)] + public SwitchParameter AzurePipelines { get; set; } + + [Parameter(ParameterSetName = "AzurePipelines", Mandatory = true)] + [ValidateNotNullOrEmpty] + public string ServiceConnectionId { get; set; } + + [Parameter(ParameterSetName = "AzurePipelines", Mandatory = true)] + [ValidateNotNullOrEmpty] + public string SystemAccessToken { get; set; } + [Parameter(ParameterSetName = "ClientSecret", Mandatory = true)] [ValidateNotNullOrEmpty] public string ClientSecret { get; set; } @@ -146,6 +162,7 @@ public class GetAzToken : PSLoggerCmdletBase [Parameter(ParameterSetName = "ClientSecret")] [Parameter(ParameterSetName = "ClientCertificate")] [Parameter(ParameterSetName = "ClientCertificatePath")] + [Parameter(ParameterSetName = "AzurePipelines")] public SwitchParameter Force { get; set; } // If user specifies Force, disregard earlier authentication @@ -338,6 +355,19 @@ should be return; } } + else if (AzurePipelines.IsPresent) + { + WriteVerbose($"Getting token using Azure Pipelines service connection OIDC for client '{ClientId}' (https://learn.microsoft.com/en-us/dotnet/api/azure.identity.azurepipelinescredential)."); + try + { + WriteObject(TokenManager.GetTokenAzurePipelines(Resource, Scope, Claim, ClientId, Tenant, ServiceConnectionId, SystemAccessToken, stopProcessing.Token)); + } + catch (Exception ex) + { + WriteError(new ErrorRecord(ex, "AzurePipelinesTokenError", ErrorCategory.AuthenticationError, null)); + return; + } + } else if (ParameterSetName == "ClientSecret") { WriteVerbose($"Getting token using client secret for client '{ClientId}' (https://learn.microsoft.com/en-us/dotnet/api/azure.identity.clientsecretcredential)."); diff --git a/tests/Get-AzToken.Tests.ps1 b/tests/Get-AzToken.Tests.ps1 index 00373d0..2a79636 100644 --- a/tests/Get-AzToken.Tests.ps1 +++ b/tests/Get-AzToken.Tests.ps1 @@ -14,6 +14,7 @@ BeforeDiscovery { @{ Name = 'ClientSecret'; Mandatory = $false } @{ Name = 'ClientCertificate'; Mandatory = $false } @{ Name = 'ClientCertificatePath'; Mandatory = $false } + @{ Name = 'AzurePipelines'; Mandatory = $false } ) } @{ @@ -30,6 +31,7 @@ BeforeDiscovery { @{ Name = 'ClientSecret'; Mandatory = $false } @{ Name = 'ClientCertificate'; Mandatory = $false } @{ Name = 'ClientCertificatePath'; Mandatory = $false } + @{ Name = 'AzurePipelines'; Mandatory = $false } ) } @{ @@ -46,6 +48,7 @@ BeforeDiscovery { @{ Name = 'ClientSecret'; Mandatory = $true } @{ Name = 'ClientCertificate'; Mandatory = $true } @{ Name = 'ClientCertificatePath'; Mandatory = $true } + @{ Name = 'AzurePipelines'; Mandatory = $true } ) } @{ @@ -62,6 +65,7 @@ BeforeDiscovery { @{ Name = 'ClientSecret'; Mandatory = $false } @{ Name = 'ClientCertificate'; Mandatory = $false } @{ Name = 'ClientCertificatePath'; Mandatory = $false } + @{ Name = 'AzurePipelines'; Mandatory = $false } ) } @{ @@ -78,6 +82,7 @@ BeforeDiscovery { @{ Name = 'ClientSecret'; Mandatory = $true } @{ Name = 'ClientCertificate'; Mandatory = $true } @{ Name = 'ClientCertificatePath'; Mandatory = $true } + @{ Name = 'AzurePipelines'; Mandatory = $true } ) } @{ @@ -185,6 +190,27 @@ BeforeDiscovery { @{ Name = 'ClientCertificatePath'; Mandatory = $true } ) } + @{ + Name = 'AzurePipelines' + Type = 'System.Management.Automation.SwitchParameter' + ParameterSets = @( + @{ Name = 'AzurePipelines'; Mandatory = $true } + ) + } + @{ + Name = 'ServiceConnectionId' + Type = 'string' + ParameterSets = @( + @{ Name = 'AzurePipelines'; Mandatory = $true } + ) + } + @{ + Name = 'SystemAccessToken' + Type = 'string' + ParameterSets = @( + @{ Name = 'AzurePipelines'; Mandatory = $true } + ) + } @{ Name = 'Force' Type = 'System.Management.Automation.SwitchParameter' @@ -197,6 +223,7 @@ BeforeDiscovery { @{ Name = 'ClientSecret'; Mandatory = $false } @{ Name = 'ClientCertificate'; Mandatory = $false } @{ Name = 'ClientCertificatePath'; Mandatory = $false } + @{ Name = 'AzurePipelines'; Mandatory = $false } ) } )