Skip to content

Commit faa23a0

Browse files
authored
Implement AzurePipelinesCredential (Azure#43686)
1 parent 26b9a4b commit faa23a0

13 files changed

+474
-47
lines changed

.vscode/cspell.json

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,13 @@
9292
"kusto",
9393
"lucene",
9494
"mgmt",
95+
"miscs",
9596
"msal",
9697
"Mtls",
9798
"northcentralus",
9899
"nunit",
99100
"odata",
101+
"oidc",
100102
"onco",
101103
"opinsights",
102104
"otel",
@@ -134,8 +136,7 @@
134136
"vnet",
135137
"westcentralus",
136138
"westus",
137-
"xunit",
138-
"miscs"
139+
"xunit"
139140
],
140141
"overrides": [
141142
{
@@ -450,7 +451,7 @@
450451
"Twilio",
451452
"Vertica",
452453
"Xero",
453-
"Soql"
454+
"Soql"
454455
]
455456
},
456457
{
@@ -869,15 +870,15 @@
869870
{
870871
"filename": "**/sdk/paloaltonetworks/**/*",
871872
"words": [
872-
"armid",
873-
"cpus",
874-
"eastus",
875-
"cloudngfw",
876-
"fqdnlists",
877-
"isvtestuklegacy",
878-
"liftr",
879-
"liftrpantestplan",
880-
"msal",
873+
"armid",
874+
"cpus",
875+
"eastus",
876+
"cloudngfw",
877+
"fqdnlists",
878+
"isvtestuklegacy",
879+
"liftr",
880+
"liftrpantestplan",
881+
"msal",
881882
"ngfw",
882883
"panrsid",
883884
"palo",
@@ -936,7 +937,7 @@
936937
{
937938
"filename": "**/sdk/qumulo/**/*",
938939
"words": [
939-
"qumulo"
940+
"qumulo"
940941
]
941942
},
942943
{
@@ -1337,7 +1338,7 @@
13371338
"protobuf",
13381339
"Ackable",
13391340
"awps"
1340-
]
1341+
]
13411342
},
13421343
{
13431344
"filename": "**/sdk/openai/**/*.cs",

sdk/identity/Azure.Identity/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Features Added
66
- `ClientAssertionCredentialOptions` now supports `TokenCachePersistenceOptions` for configuring token cache persistence.
7+
- Added `AzurePipelinesCredential` for authenticating with Azure Pipelines service connections.
78

89
### Breaking Changes
910

sdk/identity/Azure.Identity/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ Not all credentials require this configuration. Credentials which authenticate t
208208

209209
|Credential | Usage | Reference
210210
|-|-|-
211+
|`AzurePipelinesCredential`|Supports [Microsoft Entra Workload ID](https://learn.microsoft.com/azure/devops/pipelines/release/configure-workload-identity?view=azure-devops) on Azure Pipelines.| [example](https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/identity/Azure.Identity/samples/OtherCredentialSamples.md#AzurePipelinesCredential_example)
211212
|[`ClientAssertionCredential`][ref_ClientAssertionCredential]|Authenticates a service principal using a signed client assertion. |
212213
|[`ClientCertificateCredential`][ref_ClientCertificateCredential]|Authenticates a service principal using a certificate. | [Service principal authentication](https://learn.microsoft.com/entra/identity-platform/app-objects-and-service-principals)
213214
|[`ClientSecretCredential`][ref_ClientSecretCredential]|Authenticates a service principal using a secret. | [Service principal authentication](https://learn.microsoft.com/entra/identity-platform/app-objects-and-service-principals)

sdk/identity/Azure.Identity/api/Azure.Identity.netstandard2.0.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,20 @@ public AzureDeveloperCliCredentialOptions() { }
7878
public System.TimeSpan? ProcessTimeout { get { throw null; } set { } }
7979
public string TenantId { get { throw null; } set { } }
8080
}
81+
public partial class AzurePipelinesCredential : Azure.Core.TokenCredential
82+
{
83+
protected AzurePipelinesCredential() { }
84+
public AzurePipelinesCredential(string tenantId, string clientId, string serviceConnectionId, Azure.Identity.AzurePipelinesCredentialOptions options = null) { }
85+
public override Azure.Core.AccessToken GetToken(Azure.Core.TokenRequestContext requestContext, System.Threading.CancellationToken cancellationToken) { throw null; }
86+
public override System.Threading.Tasks.ValueTask<Azure.Core.AccessToken> GetTokenAsync(Azure.Core.TokenRequestContext requestContext, System.Threading.CancellationToken cancellationToken) { throw null; }
87+
}
88+
public partial class AzurePipelinesCredentialOptions : Azure.Identity.TokenCredentialOptions
89+
{
90+
public AzurePipelinesCredentialOptions() { }
91+
public System.Collections.Generic.IList<string> AdditionallyAllowedTenants { get { throw null; } }
92+
public bool DisableInstanceDiscovery { get { throw null; } set { } }
93+
public Azure.Identity.TokenCachePersistenceOptions TokenCachePersistenceOptions { get { throw null; } set { } }
94+
}
8195
public partial class AzurePowerShellCredential : Azure.Core.TokenCredential
8296
{
8397
public AzurePowerShellCredential() { }
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# AzurePipelinesCredential Example
2+
3+
This example demonstrates authenticating the `SecretClient` using the `AzurePipelinesCredential` in an Azure Pipelines environment with [service connections](https://learn.microsoft.com/azure/devops/pipelines/library/service-endpoints).
4+
5+
```C# Snippet:AzurePipelinesCredential_Example
6+
// Replace the following values with the actual values for the service connection.
7+
string clientId = "<service_connection_client_id>";
8+
string tenantId = "<service_connection_tenant_id>";
9+
string serviceConnectionId = "<service_connection_id>";
10+
11+
// Construct the credential.
12+
var credential = new AzurePipelinesCredential(tenantId, clientId, serviceConnectionId);
13+
14+
// Use the credential to authenticate with the Key Vault client.
15+
var client = new SecretClient(new Uri("https://keyvault-name.vault.azure.net/"), credential);
16+
```
17+
18+
***Note:*** This credential is **not** included in the `DefaultAzureCredential` chain and must be used explicitly.
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using System;
5+
using System.Text.Json;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
using Azure.Core;
9+
using Azure.Core.Pipeline;
10+
using Microsoft.Identity.Client;
11+
12+
namespace Azure.Identity
13+
{
14+
/// <summary>
15+
/// Credential which authenticates using an Azure Pipelines service connection.
16+
/// </summary>
17+
public class AzurePipelinesCredential : TokenCredential
18+
{
19+
internal readonly string[] AdditionallyAllowedTenantIds;
20+
internal string TenantId { get; }
21+
internal string ClientId { get; }
22+
internal MsalConfidentialClient Client { get; }
23+
internal CredentialPipeline Pipeline { get; }
24+
internal TenantIdResolverBase TenantIdResolver { get; }
25+
private const string OIDC_API_VERSION = "7.1";
26+
27+
/// <summary>
28+
/// Protected constructor for mocking.
29+
/// </summary>
30+
protected AzurePipelinesCredential()
31+
{ }
32+
33+
/// <summary>
34+
/// Creates a new instance of the <see cref="AzurePipelinesCredential"/>.
35+
/// </summary>
36+
/// <param name="tenantId">The tenant ID for the service connection.</param>
37+
/// <param name="clientId">The client ID for the service connection.</param>
38+
/// <param name="serviceConnectionId">The service connection ID, as found in the querystring's resourceId key.</param>
39+
/// <param name="options">An instance of <see cref="AzurePipelinesCredentialOptions"/>.</param>
40+
/// <exception cref="System.ArgumentNullException">When <paramref name="tenantId"/>, <paramref name="clientId"/>, or <paramref name="serviceConnectionId"/> is null.</exception>
41+
public AzurePipelinesCredential(string tenantId, string clientId, string serviceConnectionId, AzurePipelinesCredentialOptions options = default)
42+
{
43+
Argument.AssertNotNull(serviceConnectionId, nameof(serviceConnectionId));
44+
Argument.AssertNotNull(clientId, nameof(clientId));
45+
Argument.AssertNotNull(tenantId, nameof(tenantId));
46+
47+
TenantId = Validations.ValidateTenantId(tenantId, nameof(tenantId));
48+
ClientId = clientId;
49+
Pipeline = options?.Pipeline ?? CredentialPipeline.GetInstance(options);
50+
51+
Func<CancellationToken, Task<string>> _assertionCallback = async (cancellationToken) =>
52+
{
53+
var message = CreateOidcRequestMessage(serviceConnectionId, options ?? new AzurePipelinesCredentialOptions());
54+
await Pipeline.HttpPipeline.SendAsync(message, cancellationToken).ConfigureAwait(false);
55+
return GetOidcTokenResponse(message);
56+
};
57+
58+
Client = options?.MsalClient ?? new MsalConfidentialClient(Pipeline, tenantId, clientId, _assertionCallback, options);
59+
TenantIdResolver = options?.TenantIdResolver ?? TenantIdResolverBase.Default;
60+
AdditionallyAllowedTenantIds = TenantIdResolver.ResolveAddionallyAllowedTenantIds((options as ISupportsAdditionallyAllowedTenants)?.AdditionallyAllowedTenants);
61+
}
62+
63+
/// <inheritdoc />
64+
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
65+
=> GetTokenCoreAsync(false, requestContext, cancellationToken).EnsureCompleted();
66+
67+
/// <inheritdoc />
68+
public override async ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
69+
=> await GetTokenCoreAsync(true, requestContext, cancellationToken).ConfigureAwait(false);
70+
71+
internal async ValueTask<AccessToken> GetTokenCoreAsync(bool async, TokenRequestContext requestContext, CancellationToken cancellationToken)
72+
{
73+
using CredentialDiagnosticScope scope = Pipeline.StartGetTokenScope("AzurePipelinesCredential.GetToken", requestContext);
74+
75+
try
76+
{
77+
var tenantId = TenantIdResolver.Resolve(TenantId, requestContext, AdditionallyAllowedTenantIds);
78+
79+
AuthenticationResult result = await Client.AcquireTokenForClientAsync(requestContext.Scopes, tenantId, requestContext.Claims, requestContext.IsCaeEnabled, async, cancellationToken).ConfigureAwait(false);
80+
81+
return scope.Succeeded(new AccessToken(result.AccessToken, result.ExpiresOn));
82+
}
83+
catch (Exception e)
84+
{
85+
throw scope.FailWrapAndThrow(e);
86+
}
87+
}
88+
89+
internal HttpMessage CreateOidcRequestMessage(string serviceConnectionId, AzurePipelinesCredentialOptions options)
90+
{
91+
string CollectionUri = options.CollectionUri ?? throw new CredentialUnavailableException("AzurePipelinesCredential is not available: environment variable SYSTEM_TEAMFOUNDATIONCOLLECTIONURI is not set.");
92+
string projectId = options.TeamProjectId ?? throw new CredentialUnavailableException("AzurePipelinesCredential is not available: environment variable SYSTEM_TEAMPROJECTID is not set.");
93+
string planId = options.PlanId ?? throw new CredentialUnavailableException("AzurePipelinesCredential is not available: environment variable SYSTEM_PLANID is not set.");
94+
string jobId = options.JobId ?? throw new CredentialUnavailableException("AzurePipelinesCredential is not available: environment variable SYSTEM_JOBID is not set.");
95+
string systemToken = options.SystemAccessToken ?? throw new CredentialUnavailableException("AzurePipelinesCredential is not available: environment variable SYSTEM_ACCESSTOKEN is not set.");
96+
97+
var message = Pipeline.HttpPipeline.CreateMessage();
98+
99+
var requestUri = new Uri($"{CollectionUri}{projectId}/_apis/distributedtask/hubs/build/plans/{planId}/jobs/{jobId}/oidctoken?api-version={OIDC_API_VERSION}&serviceConnectionId={serviceConnectionId}");
100+
message.Request.Uri.Reset(requestUri);
101+
message.Request.Headers.SetValue(HttpHeader.Names.Authorization, $"Bearer {systemToken}");
102+
message.Request.Headers.SetValue(HttpHeader.Names.ContentType, "application/json");
103+
return message;
104+
}
105+
106+
internal string GetOidcTokenResponse(HttpMessage message)
107+
{
108+
Utf8JsonReader reader = new Utf8JsonReader(message.Response.Content);
109+
string oidcToken = null;
110+
while (oidcToken is null && reader.Read())
111+
{
112+
if (reader.TokenType == JsonTokenType.PropertyName)
113+
{
114+
switch (reader.GetString())
115+
{
116+
case "oidcToken":
117+
reader.Read();
118+
oidcToken = reader.GetString();
119+
break;
120+
}
121+
}
122+
}
123+
return oidcToken ?? throw new AuthenticationFailedException("OIDC token not found in response.");
124+
}
125+
}
126+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
7+
namespace Azure.Identity
8+
{
9+
/// <summary>
10+
/// Options used to configure the <see cref="AzurePipelinesCredential"/>.
11+
/// </summary>
12+
public class AzurePipelinesCredentialOptions : TokenCredentialOptions, ISupportsDisableInstanceDiscovery, ISupportsAdditionallyAllowedTenants, ISupportsTokenCachePersistenceOptions
13+
{
14+
internal CredentialPipeline Pipeline { get; set; }
15+
16+
internal MsalConfidentialClient MsalClient { get; set; }
17+
18+
/// <summary>
19+
/// The security token used by the running build.
20+
/// </summary>
21+
internal string SystemAccessToken { get; set; } = Environment.GetEnvironmentVariable("SYSTEM_ACCESSTOKEN");
22+
23+
/// <summary>
24+
/// The URI of the TFS collection or Azure DevOps organization.
25+
/// </summary>
26+
internal string CollectionUri { get; set; } = Environment.GetEnvironmentVariable("SYSTEM_TEAMFOUNDATIONCOLLECTIONURI");
27+
28+
/// <summary>
29+
/// A unique identifier for a single attempt of a single job. The value is unique to the current pipeline.
30+
/// </summary>
31+
internal string JobId { get; set; } = Environment.GetEnvironmentVariable("SYSTEM_JOBID");
32+
33+
/// <summary>
34+
/// A string-based identifier for a single pipeline run.
35+
/// </summary>
36+
internal string PlanId { get; set; } = Environment.GetEnvironmentVariable("SYSTEM_PLANID");
37+
38+
/// <summary>
39+
/// The ID of the project that this build belongs to.
40+
/// </summary>
41+
internal string TeamProjectId { get; set; } = Environment.GetEnvironmentVariable("SYSTEM_TEAMPROJECTID");
42+
43+
/// <inheritdoc/>
44+
public IList<string> AdditionallyAllowedTenants { get; internal set; } = new List<string>();
45+
46+
/// <inheritdoc/>
47+
public bool DisableInstanceDiscovery { get; set; }
48+
49+
/// <inheritdoc/>
50+
public TokenCachePersistenceOptions TokenCachePersistenceOptions { get; set; }
51+
}
52+
}

sdk/identity/Azure.Identity/src/Credentials/ClientAssertionCredential.cs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,10 @@ namespace Azure.Identity
1818
public class ClientAssertionCredential : TokenCredential
1919
{
2020
internal readonly string[] AdditionallyAllowedTenantIds;
21-
2221
internal string TenantId { get; }
2322
internal string ClientId { get; }
2423
internal MsalConfidentialClient Client { get; }
2524
internal CredentialPipeline Pipeline { get; }
26-
internal bool AllowMultiTenantAuthentication { get; }
2725
internal TenantIdResolverBase TenantIdResolver { get; }
2826

2927
/// <summary>
@@ -46,8 +44,8 @@ public ClientAssertionCredential(string tenantId, string clientId, Func<Cancella
4644
TenantId = Validations.ValidateTenantId(tenantId, nameof(tenantId));
4745
ClientId = clientId;
4846

49-
Client = options?.MsalClient ?? new MsalConfidentialClient(options?.Pipeline ?? CredentialPipeline.GetInstance(options), tenantId, clientId, assertionCallback, options);
50-
Pipeline = options?.Pipeline ?? options?.Pipeline ?? CredentialPipeline.GetInstance(options);
47+
Pipeline = options?.Pipeline ?? CredentialPipeline.GetInstance(options);
48+
Client = options?.MsalClient ?? new MsalConfidentialClient(Pipeline, tenantId, clientId, assertionCallback, options);
5149
TenantIdResolver = options?.TenantIdResolver ?? TenantIdResolverBase.Default;
5250
AdditionallyAllowedTenantIds = TenantIdResolver.ResolveAddionallyAllowedTenantIds((options as ISupportsAdditionallyAllowedTenants)?.AdditionallyAllowedTenants);
5351
}

0 commit comments

Comments
 (0)