From ae7cd592c5d93353b20dec704ac320a6764b5f9d Mon Sep 17 00:00:00 2001
From: embetten <53092095+embetten@users.noreply.github.com>
Date: Mon, 10 Jun 2024 11:45:29 -0700
Subject: [PATCH] Managed Identity and Service Principal Support (#492)
# Overview
- Added MSAL Managed Identity and Service Principal Token Providers to
Microsoft.Artifacts.Authentication Library.
- Created new endpoint `ARTIFACTS_CREDENTIALPROVIDER_FEED_ENDPOINTS`
environment variable with new json schema for MI/SP required fields.
- Updated VstsBuildTaskServiceEndpointCredentialProvider to call
Microsoft.Artifacts.Authentication for MI/SP token providers.
- Reverted #485 Changes to use system.text.json for de/serialization
everywhere except for the `VSS_NUGET_EXTERNAL_FEED_ENDPOINTS`
environment variable.
## Design Decisions
- Intentionally not supporting SP secrets authentication to promote
security best practices.
- The new environment variable name and json schema were created instead
of reusing or extending the existing `VSS_NUGET_EXTERNAL_FEED_ENDPOINTS`
to reduce password usage and clarify the environment variable will be
available to our other credproviders such as the
[artifacs-keyring](https://github.com/microsoft/artifacts-keyring) not
just NuGet.
## Environment Variable
`ARTIFACTS_CREDENTIALPROVIDER_FEED_ENDPOINTS`
```javascript
{"endpointCredentials": [{"endpoint":"http://example.index.json", "clientId":"required", "clientCertificateSubjectName":"optional", "clientCertificateFilePath":"optional"}]}
```
- `endpoint`: required. Feed url to authenticate against.
- `clientId`: required for both MI/SP. For user assigned managed
identities enter the Entra client id. For system assigned variables set
the value to `system`.
- `clientCertificateSubjectName`: Subject Name of the certificate
located in the My/ CurrentUser or LocalMachine certificate store.
Optional field. Only used by SP authentication.
- `clientCertificateFilePath`: File path location of the certificate on
the machine. Optional field. Only used by SP authentication.
Will throw error if both `clientCertificateSubjectName` or
`clientCertificateFilePath` are specified.
---
.../CredentialProvider.Microsoft.Tests.csproj | 3 +-
.../CredentialProviders/Vsts/AuthUtilTests.cs | 47 +-
.../Vsts/VstsCredentialProviderTests.cs | 4 +-
...kServiceEndpointCredentialProviderTests.cs | 420 +++++++++++-------
.../Util/FeedEndpointCredentialParserTests.cs | 187 ++++++++
.../CredentialProviders/Vsts/IAuthUtil.cs | 103 +++--
.../Vsts/VstsCredentialProvider.cs | 16 +-
.../Vsts/VstsSessionTokenClient.cs | 13 +-
.../VstsBuildTaskMsalTokenProvidersFactory.cs | 47 ++
...ldTaskServiceEndpointCredentialProvider.cs | 179 ++++----
CredentialProvider.Microsoft/Program.cs | 8 +-
CredentialProvider.Microsoft/Resources.resx | 58 ++-
.../Util/CertificateUtil.cs | 74 +++
CredentialProvider.Microsoft/Util/EnvUtil.cs | 4 +-
.../Util/FeedEndpointCredentialsParser.cs | 170 +++++++
.../Util/SessionTokenCache.cs | 28 +-
Directory.Packages.props | 1 +
.../MsalAuthenticationTests.cs | 27 ++
.../TokenProviderTests.cs | 56 +++
src/Authentication.Tests/Usings.cs | 1 +
src/Authentication/MsalConstants.cs | 2 +-
.../MsalManagedIdentityTokenProvider.cs | 64 +++
.../MsalServicePrincipalTokenProvider.cs | 57 +++
src/Authentication/MsalTokenProviders.cs | 3 +
src/Authentication/Resources.resx | 59 +--
src/Authentication/TokenRequest.cs | 7 +
26 files changed, 1269 insertions(+), 369 deletions(-)
create mode 100644 CredentialProvider.Microsoft.Tests/Util/FeedEndpointCredentialParserTests.cs
create mode 100644 CredentialProvider.Microsoft/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskMsalTokenProvidersFactory.cs
create mode 100644 CredentialProvider.Microsoft/Util/CertificateUtil.cs
create mode 100644 CredentialProvider.Microsoft/Util/FeedEndpointCredentialsParser.cs
create mode 100644 src/Authentication/MsalManagedIdentityTokenProvider.cs
create mode 100644 src/Authentication/MsalServicePrincipalTokenProvider.cs
diff --git a/CredentialProvider.Microsoft.Tests/CredentialProvider.Microsoft.Tests.csproj b/CredentialProvider.Microsoft.Tests/CredentialProvider.Microsoft.Tests.csproj
index 2b475a4c..7f86fc08 100644
--- a/CredentialProvider.Microsoft.Tests/CredentialProvider.Microsoft.Tests.csproj
+++ b/CredentialProvider.Microsoft.Tests/CredentialProvider.Microsoft.Tests.csproj
@@ -4,6 +4,8 @@
latest
false
+ true
+ true
@@ -18,5 +20,4 @@
-
diff --git a/CredentialProvider.Microsoft.Tests/CredentialProviders/Vsts/AuthUtilTests.cs b/CredentialProvider.Microsoft.Tests/CredentialProviders/Vsts/AuthUtilTests.cs
index ae77e6a4..1f7f9414 100644
--- a/CredentialProvider.Microsoft.Tests/CredentialProviders/Vsts/AuthUtilTests.cs
+++ b/CredentialProvider.Microsoft.Tests/CredentialProviders/Vsts/AuthUtilTests.cs
@@ -45,27 +45,27 @@ public void TestCleanup()
}
[TestMethod]
- public async Task GetAadAuthorityUri_WithoutAuthenticateHeaders_ReturnsCorrectAuthority()
+ public async Task GetAuthorizationInfoAsync_WithoutAuthenticateHeaders_ReturnsCorrectAuthority()
{
var requestUri = new Uri("https://example.pkgs.visualstudio.com/_packaging/feed/nuget/v3/index.json");
- var authorityUri = await authUtil.GetAadAuthorityUriAsync(requestUri, cancellationToken);
+ var authInfo = await authUtil.GetAuthorizationInfoAsync(requestUri, cancellationToken);
- authorityUri.Should().Be(organizationsAuthority);
+ authInfo.EntraAuthorityUri.Should().Be(organizationsAuthority);
}
[TestMethod]
- public async Task GetAadAuthorityUri_WithoutAuthenticateHeadersAndPpe_ReturnsCorrectAuthority()
+ public async Task GetAuthorizationInfoAsync_WithoutAuthenticateHeadersAndPpe_ReturnsCorrectAuthority()
{
var requestUri = new Uri("https://example.pkgs.vsts.me/_packaging/feed/nuget/v3/index.json");
- var authorityUri = await authUtil.GetAadAuthorityUriAsync(requestUri, cancellationToken);
+ var authInfo = await authUtil.GetAuthorizationInfoAsync(requestUri, cancellationToken);
- authorityUri.Should().Be(new Uri("https://login.windows-ppe.net/organizations"));
+ authInfo.EntraAuthorityUri.Should().Be(new Uri("https://login.windows-ppe.net/organizations"));
}
[TestMethod]
- public async Task GetAadAuthorityUri_WithoutAuthenticateHeadersAndPpeAndPpeOverride_ReturnsCorrectAuthority()
+ public async Task GetAuthorizationInfoAsync_WithoutAuthenticateHeadersAndPpeAndPpeOverride_ReturnsCorrectAuthority()
{
var ppeUris = new[]
{
@@ -79,26 +79,26 @@ public async Task GetAadAuthorityUri_WithoutAuthenticateHeadersAndPpeAndPpeOverr
foreach (var ppeUri in ppeUris)
{
- var authorityUri = await authUtil.GetAadAuthorityUriAsync(ppeUri, cancellationToken);
+ var authInfo = await authUtil.GetAuthorizationInfoAsync(ppeUri, cancellationToken);
- authorityUri.Should().Be(new Uri("https://login.windows-ppe.net/organizations"));
+ authInfo.EntraAuthorityUri.Should().Be(new Uri("https://login.windows-ppe.net/organizations"));
}
}
[TestMethod]
- public async Task GetAadAuthorityUri_WithAuthenticateHeaders_ReturnsCorrectAuthority()
+ public async Task GetAuthorizationInfoAsync_WithAuthenticateHeaders_ReturnsCorrectAuthority()
{
var requestUri = new Uri("https://example.pkgs.visualstudio.com/_packaging/feed/nuget/v3/index.json");
MockAadAuthorityHeaders(testAuthority);
- var authorityUri = await authUtil.GetAadAuthorityUriAsync(requestUri, cancellationToken);
+ var authInfo = await authUtil.GetAuthorizationInfoAsync(requestUri, cancellationToken);
- authorityUri.Should().Be(testAuthority);
+ authInfo.EntraAuthorityUri.Should().Be(testAuthority);
}
[TestMethod]
- public async Task GetAadAuthorityUri_WithAuthenticateHeadersAndEnvironmentOverride_ReturnsOverrideAuthority()
+ public async Task GetAuthorizationInfoAsync_WithAuthenticateHeadersAndEnvironmentOverride_ReturnsOverrideAuthority()
{
var requestUri = new Uri("https://example.pkgs.visualstudio.com/_packaging/feed/nuget/v3/index.json");
var overrideAuthority = new Uri("https://override.aad.authority.com");
@@ -106,9 +106,22 @@ public async Task GetAadAuthorityUri_WithAuthenticateHeadersAndEnvironmentOverri
MockAadAuthorityHeaders(testAuthority);
Environment.SetEnvironmentVariable(EnvUtil.MsalAuthorityEnvVar, overrideAuthority.ToString());
- var authorityUri = await authUtil.GetAadAuthorityUriAsync(requestUri, cancellationToken);
+ var authInfo = await authUtil.GetAuthorizationInfoAsync(requestUri, cancellationToken);
- authorityUri.Should().Be(overrideAuthority);
+ authInfo.EntraAuthorityUri.Should().Be(overrideAuthority);
+ }
+
+ [TestMethod]
+ public async Task GetAuthorizationInfoAsync_WithTenantHeaders_ReturnsCorrectTenantId()
+ {
+ var requestUri = new Uri("https://example.pkgs.visualstudio.com/_packaging/feed/nuget/v3/index.json");
+
+ var testTenant = Guid.NewGuid();
+ MockVssResourceTenantHeader(testTenant);
+
+ var authInfo = await authUtil.GetAuthorizationInfoAsync(requestUri, cancellationToken);
+
+ authInfo.EntraTenantId.Should().Be(testTenant.ToString());
}
[TestMethod]
@@ -203,9 +216,9 @@ private void MockResponseHeaders(string key, string value)
authUtil.HttpResponseHeaders.Add(key, value);
}
- private void MockVssResourceTenantHeader()
+ private void MockVssResourceTenantHeader(Guid? guid = null)
{
- MockResponseHeaders(AuthUtil.VssResourceTenant, Guid.NewGuid().ToString());
+ MockResponseHeaders(AuthUtil.VssResourceTenant,(guid ?? Guid.NewGuid()).ToString());
}
private void MockVssAuthorizationEndpointHeader()
diff --git a/CredentialProvider.Microsoft.Tests/CredentialProviders/Vsts/VstsCredentialProviderTests.cs b/CredentialProvider.Microsoft.Tests/CredentialProviders/Vsts/VstsCredentialProviderTests.cs
index 163a9e74..dedc44be 100644
--- a/CredentialProvider.Microsoft.Tests/CredentialProviders/Vsts/VstsCredentialProviderTests.cs
+++ b/CredentialProvider.Microsoft.Tests/CredentialProviders/Vsts/VstsCredentialProviderTests.cs
@@ -53,8 +53,8 @@ public void TestInitialize()
mockAuthUtil = new Mock();
mockAuthUtil
- .Setup(x => x.GetAadAuthorityUriAsync(It.IsAny(), It.IsAny()))
- .Returns(Task.FromResult(testAuthority));
+ .Setup(x => x.GetAuthorizationInfoAsync(It.IsAny(), It.IsAny()))
+ .Returns(Task.FromResult(new AuthorizationInfo() { EntraAuthorityUri = testAuthority }));
vstsCredentialProvider = new VstsCredentialProvider(
mockLogger.Object,
diff --git a/CredentialProvider.Microsoft.Tests/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProviderTests.cs b/CredentialProvider.Microsoft.Tests/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProviderTests.cs
index bfb3cfcb..bc17a31b 100644
--- a/CredentialProvider.Microsoft.Tests/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProviderTests.cs
+++ b/CredentialProvider.Microsoft.Tests/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProviderTests.cs
@@ -3,179 +3,275 @@
// Licensed under the MIT license.
using System;
+using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using CredentialProvider.Microsoft.Tests.CredentialProviders.Vsts;
-using FluentAssertions;
+using Microsoft.Artifacts.Authentication;
+using Microsoft.Identity.Client;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using NuGet.Protocol.Plugins;
+using NuGetCredentialProvider.CredentialProviders.Vsts;
using NuGetCredentialProvider.CredentialProviders.VstsBuildTaskServiceEndpoint;
using NuGetCredentialProvider.Logging;
using NuGetCredentialProvider.Util;
-namespace CredentialProvider.Microsoft.Tests.CredentialProviders.VstsBuildTaskServiceEndpoint
+namespace CredentialProvider.Microsoft.Tests.CredentialProviders.VstsBuildTaskServiceEndpoint;
+
+[TestClass]
+public class VstsBuildTaskServiceEndpointCredentialProviderTests
{
- [TestClass]
- public class VstsBuildTaskServiceEndpointCredentialProviderTests
+
+ private Mock mockLogger;
+
+ private Mock mockTokenProviderFactory;
+
+ private VstsBuildTaskServiceEndpointCredentialProvider vstsCredentialProvider;
+
+ private IDisposable environmentLock;
+
+ [TestInitialize]
+ public void TestInitialize()
+ {
+ mockLogger = new Mock();
+ var mockAuthUtil = new Mock();
+ mockTokenProviderFactory = new Mock();
+
+ vstsCredentialProvider = new VstsBuildTaskServiceEndpointCredentialProvider(
+ mockLogger.Object,
+ mockTokenProviderFactory.Object,
+ mockAuthUtil.Object);
+ environmentLock = EnvironmentLock.WaitAsync().Result;
+ }
+
+ [TestCleanup]
+ public virtual void TestCleanup()
+ {
+ Environment.SetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints, null);
+ Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, null);
+ environmentLock?.Dispose();
+ }
+
+ [TestMethod]
+ public async Task CanProvideCredentials_ReturnsFalseForWhenEnvVarIsNotSet()
+ {
+ Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json");
+ string feedEndPointJsonEnvVarold = EnvUtil.BuildTaskExternalEndpoints;
+ string feedEndPointJsonEnvVarnew = EnvUtil.EndpointCredentials;
+
+ // Setting environment variable to null
+ Environment.SetEnvironmentVariable(feedEndPointJsonEnvVarold, null);
+ Environment.SetEnvironmentVariable(feedEndPointJsonEnvVarnew, null);
+
+ var result = await vstsCredentialProvider.CanProvideCredentialsAsync(sourceUri);
+ Assert.AreEqual(false, result);
+ }
+
+ [DataTestMethod]
+ [DataRow(EnvUtil.BuildTaskExternalEndpoints)]
+ [DataRow(EnvUtil.EndpointCredentials)]
+ public async Task CanProvideCredentials_ReturnsTrueForCorrectEnvironmentVariable(string feedEndPointJsonEnvVar)
+ {
+ Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json");
+ string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser\", \"password\":\"testToken\"}]}";
+
+ Environment.SetEnvironmentVariable(feedEndPointJsonEnvVar, feedEndPointJson);
+
+ var result = await vstsCredentialProvider.CanProvideCredentialsAsync(sourceUri);
+ Assert.AreEqual(true, result);
+ }
+
+ [TestMethod]
+ public async Task HandleRequestAsync_WithExternalEndpoint_ReturnsSuccess()
+ {
+ Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json");
+ string feedEndPointJsonEnvVar = EnvUtil.BuildTaskExternalEndpoints;
+ string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser\", \"password\":\"testToken\"}]}";
+
+ Environment.SetEnvironmentVariable(feedEndPointJsonEnvVar, feedEndPointJson);
+
+ var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None);
+ Assert.AreEqual(result.ResponseCode, MessageResponseCode.Success);
+ Assert.AreEqual(result.Username, "testUser");
+ Assert.AreEqual(result.Password, "testToken");
+ }
+
+ [TestMethod]
+ public async Task HandleRequestAsync_WithEndpoint_ReturnsSuccess()
+ {
+ Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json");
+ string feedEndPointJsonEnvVar = EnvUtil.EndpointCredentials;
+ string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"clientId\": \"testClientId\"}]}";
+
+ Environment.SetEnvironmentVariable(feedEndPointJsonEnvVar, feedEndPointJson);
+ Environment.SetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints, null);
+
+ mockTokenProviderFactory.Setup(x =>
+ x.GetAsync(It.IsAny()))
+ .ReturnsAsync(new List()
+ {
+ SetUpMockManagedIdentityTokenProvider("someTokenValue").Object
+ });
+
+ var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None);
+ Assert.AreEqual(result.ResponseCode, MessageResponseCode.Success);
+ Assert.AreEqual(result.Username, "testClientId");
+ Assert.AreEqual(result.Password, "someTokenValue");
+ }
+
+ [TestMethod]
+ public async Task HandleRequestAsync_ExternalEndpoints_ReturnsSuccessWhenMultipleSourcesInJson()
+ {
+ Uri sourceUri = new Uri(@"http://example3.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json");
+
+ string feedEndPointJson = "{\"endpointCredentials\":[" +
+ "{\"endpoint\":\"http://example1.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser1\", \"password\":\"testToken1\"}, " +
+ "{\"endpoint\":\"http://example2.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser2\", \"password\":\"testToken2\"}, " +
+ "{\"endpoint\":\"http://example3.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser3\", \"password\":\"testToken3\"}, " +
+ "{\"endpoint\":\"http://example4.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser4\", \"password\":\"testToken4\"}" +
+ "]}";
+
+ Environment.SetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints, feedEndPointJson);
+
+ var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None);
+ Assert.AreEqual(result.ResponseCode, MessageResponseCode.Success);
+ Assert.AreEqual(result.Username, "testUser3");
+ Assert.AreEqual(result.Password, "testToken3");
+ }
+
+ [TestMethod]
+ public async Task HandleRequestAsync_ExternalEndpoints_ReturnsErrorWhenMatchingEndpointIsNotFound()
+ {
+ Uri sourceUri = new Uri(@"http://exampleThatDoesNotMatch.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json");
+ string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser\", \"password\":\"testToken\"}]}";
+
+ Environment.SetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints, feedEndPointJson);
+
+ var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None);
+ Assert.AreEqual(result.ResponseCode, MessageResponseCode.Error);
+ }
+
+ [TestMethod]
+ public async Task HandleRequestAsync_Endpoints_ReturnsErrorWhenMatchingEndpointIsNotFound()
+ {
+ Uri sourceUri = new Uri(@"http://exampleThatDoesNotMatch.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json");
+ string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"clientId\": \"someClientId\"}]}";
+
+ Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, feedEndPointJson);
+
+ var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None);
+ Assert.AreEqual(result.ResponseCode, MessageResponseCode.Error);
+ }
+
+ [TestMethod]
+ public async Task HandleRequestAsync_MatchesEndpointURLCaseInsensitive()
+ {
+ Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_Packaging/TestFEED/nuget/v3/index.json");
+
+ string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser\", \"password\":\"testToken\"}]}";
+
+ Environment.SetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints, feedEndPointJson);
+
+ var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None);
+ Assert.AreEqual(MessageResponseCode.Success, result.ResponseCode);
+ Assert.AreEqual("testUser", result.Username);
+ Assert.AreEqual("testToken", result.Password);
+ }
+
+ [TestMethod]
+ public async Task HandleRequestAsync_MatchesEndpointURLWithSpaces()
+ {
+ Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/My Collection/_packaging/TestFeed/nuget/v3/index.json");
+
+ string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/My Collection/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser\", \"password\":\"testToken\"}]}";
+
+ Environment.SetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints, feedEndPointJson);
+
+ var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None);
+ Assert.AreEqual(MessageResponseCode.Success, result.ResponseCode);
+ Assert.AreEqual("testUser", result.Username);
+ Assert.AreEqual("testToken", result.Password);
+ }
+
+ [TestMethod]
+ public async Task HandleRequestAsync_WithInvalidBearer_ReturnsError()
+ {
+ Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json");
+ string feedEndPointJson = $"{{\"endpointCredentials\":[{{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser\", \"azureClientId\":\"\"}}]}}";
+
+ Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, feedEndPointJson);
+
+ mockTokenProviderFactory.Setup(x =>
+ x.GetAsync(It.IsAny()))
+ .ReturnsAsync(new List() {
+ SetUpMockManagedIdentityTokenProvider(null).Object });
+
+ var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None);
+ Assert.AreEqual(result.ResponseCode, MessageResponseCode.Error);
+ }
+
+ [TestMethod]
+ public async Task HandleRequestAsync_WithNoTokenProvider_ReturnsError()
+ {
+ Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json");
+ string feedEndPointJson = $"{{\"endpointCredentials\":[{{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\",\"clientId\":\"someClientId\"}}]}}";
+
+ Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, feedEndPointJson);
+
+ var mockTokenProvider = new Mock();
+ mockTokenProvider.Setup(x => x.Name).Returns("wrong name");
+
+ mockTokenProviderFactory.Setup(x =>
+ x.GetAsync(It.IsAny()))
+ .ReturnsAsync(new List() { mockTokenProvider.Object });
+
+ var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None);
+ Assert.AreEqual(result.ResponseCode, MessageResponseCode.Error);
+ }
+
+ [TestMethod]
+ public async Task HandleRequestAsync_OnTokenProviderError_ReturnsError()
+ {
+ Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json");
+ string feedEndPointJson = $"{{\"endpointCredentials\":[{{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"clientId\":\"\"}}]}}";
+
+ Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, feedEndPointJson);
+
+ var mockTokenProvider = new Mock();
+ mockTokenProvider.Setup(x => x.Name).Returns("MSAL Managed Identity");
+ mockTokenProvider.Setup(x => x.IsInteractive).Returns(false);
+ mockTokenProvider.Setup(x => x.CanGetToken(It.IsAny())).Returns(true);
+ mockTokenProvider.Setup(x => x.GetTokenAsync(It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new Exception("some Message"));
+
+ mockTokenProviderFactory.Setup(x =>
+ x.GetAsync(It.IsAny()))
+ .ReturnsAsync(new List() { mockTokenProvider.Object });
+
+ var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None);
+ Assert.AreEqual(result.ResponseCode, MessageResponseCode.Error);
+ }
+
+ private static Mock SetUpMockManagedIdentityTokenProvider(string token)
{
- private readonly Uri feedUri = new Uri("https://example.aad.authority.com");
-
- private Mock mockLogger;
-
- private VstsBuildTaskServiceEndpointCredentialProvider vstsCredentialProvider;
-
- private IDisposable environmentLock;
-
- [TestInitialize]
- public void TestInitialize()
- {
- mockLogger = new Mock();
-
- vstsCredentialProvider = new VstsBuildTaskServiceEndpointCredentialProvider(mockLogger.Object);
- environmentLock = EnvironmentLock.WaitAsync().Result;
- }
-
- [TestCleanup]
- public virtual void TestCleanup()
- {
- environmentLock?.Dispose();
- }
-
- [TestMethod]
- public async Task CanProvideCredentials_ReturnsFalseForWhenEnvVarIsNotSet()
- {
- Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json");
- string feedEndPointJsonEnvVar = EnvUtil.BuildTaskExternalEndpoints;
-
- // Setting environment variable to null
- Environment.SetEnvironmentVariable(feedEndPointJsonEnvVar, null);
-
- var result = await vstsCredentialProvider.CanProvideCredentialsAsync(sourceUri);
- Assert.AreEqual(false, result);
- }
-
- [TestMethod]
- public async Task CanProvideCredentials_ReturnsTrueForCorrectEnvironmentVariable()
- {
- Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json");
- string feedEndPointJsonEnvVar = EnvUtil.BuildTaskExternalEndpoints;
- string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser\", \"password\":\"testToken\"}]}";
-
- Environment.SetEnvironmentVariable(feedEndPointJsonEnvVar, feedEndPointJson);
-
- var result = await vstsCredentialProvider.CanProvideCredentialsAsync(sourceUri);
- Assert.AreEqual(true, result);
- }
-
- [TestMethod]
- public async Task HandleRequestAsync_ReturnsSuccess()
- {
- Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json");
- string feedEndPointJsonEnvVar = EnvUtil.BuildTaskExternalEndpoints;
- string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser\", \"password\":\"testToken\"}]}";
-
- Environment.SetEnvironmentVariable(feedEndPointJsonEnvVar, feedEndPointJson);
-
- var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None);
- Assert.AreEqual(result.ResponseCode, MessageResponseCode.Success);
- Assert.AreEqual(result.Username, "testUser");
- Assert.AreEqual(result.Password, "testToken");
- }
-
- [TestMethod]
- public async Task HandleRequestAsync_ReturnsSuccessWhenSingleQuotesInJson()
- {
- Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json");
- string feedEndPointJsonEnvVar = EnvUtil.BuildTaskExternalEndpoints;
- string feedEndPointJson = "{\'endpointCredentials\':[{\'endpoint\':\'http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\', \'username\': \'testUser\', \'password\':\'testToken\'}]}";
-
- Environment.SetEnvironmentVariable(feedEndPointJsonEnvVar, feedEndPointJson);
-
- var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None);
- Assert.AreEqual(result.ResponseCode, MessageResponseCode.Success);
- Assert.AreEqual(result.Username, "testUser");
- Assert.AreEqual(result.Password, "testToken");
- }
-
- [TestMethod]
- public async Task HandleRequestAsync_ReturnsSuccessWhenMultipleSourcesInJson()
- {
- Uri sourceUri = new Uri(@"http://example3.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json");
- string feedEndPointJsonEnvVar = EnvUtil.BuildTaskExternalEndpoints;
-
- string feedEndPointJson = "{\"endpointCredentials\":[" +
- "{\"endpoint\":\"http://example1.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser1\", \"password\":\"testToken1\"}, " +
- "{\"endpoint\":\"http://example2.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser2\", \"password\":\"testToken2\"}, " +
- "{\"endpoint\":\"http://example3.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser3\", \"password\":\"testToken3\"}, " +
- "{\"endpoint\":\"http://example4.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser4\", \"password\":\"testToken4\"}" +
- "]}";
-
- Environment.SetEnvironmentVariable(feedEndPointJsonEnvVar, feedEndPointJson);
-
- var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None);
- Assert.AreEqual(result.ResponseCode, MessageResponseCode.Success);
- Assert.AreEqual(result.Username, "testUser3");
- Assert.AreEqual(result.Password, "testToken3");
- }
-
- [TestMethod]
- public async Task HandleRequestAsync_ReturnsErrorWhenMatchingEndpointIsNotFound()
- {
- Uri sourceUri = new Uri(@"http://exampleThatDoesNotMatch.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json");
- string feedEndPointJsonEnvVar = EnvUtil.BuildTaskExternalEndpoints;
- string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser\", \"password\":\"testToken\"}]}";
-
- Environment.SetEnvironmentVariable(feedEndPointJsonEnvVar, feedEndPointJson);
-
- var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None);
- Assert.AreEqual(result.ResponseCode, MessageResponseCode.Error);
- }
-
- [TestMethod]
- public void HandleRequestAsync_ThrowsWithInvalidJson()
- {
- Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json");
- string feedEndPointJsonEnvVar = EnvUtil.BuildTaskExternalEndpoints;
- string invalidFeedEndPointJson = "this is not json";
-
- Environment.SetEnvironmentVariable(feedEndPointJsonEnvVar, invalidFeedEndPointJson);
-
- Func act = async () => await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None);
- act.Should().Throw();
- }
-
- [TestMethod]
- public async Task HandleRequestAsync_MatchesEndpointURLCaseInsensitive()
- {
- Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/_Packaging/TestFEED/nuget/v3/index.json");
-
- string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser\", \"password\":\"testToken\"}]}";
- string feedEndPointJsonEnvVar = EnvUtil.BuildTaskExternalEndpoints;
-
- Environment.SetEnvironmentVariable(feedEndPointJsonEnvVar, feedEndPointJson);
-
- var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None);
- Assert.AreEqual(MessageResponseCode.Success, result.ResponseCode);
- Assert.AreEqual("testUser", result.Username);
- Assert.AreEqual("testToken", result.Password);
- }
-
-
- [TestMethod]
- public async Task HandleRequestAsync_MatchesEndpointURLWithSpaces()
- {
- Uri sourceUri = new Uri(@"http://example.pkgs.vsts.me/My Collection/_packaging/TestFeed/nuget/v3/index.json");
-
- string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/My Collection/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testUser\", \"password\":\"testToken\"}]}";
- string feedEndPointJsonEnvVar = EnvUtil.BuildTaskExternalEndpoints;
-
- Environment.SetEnvironmentVariable(feedEndPointJsonEnvVar, feedEndPointJson);
-
- var result = await vstsCredentialProvider.HandleRequestAsync(new GetAuthenticationCredentialsRequest(sourceUri, false, false, false), CancellationToken.None);
- Assert.AreEqual(MessageResponseCode.Success, result.ResponseCode);
- Assert.AreEqual("testUser", result.Username);
- Assert.AreEqual("testToken", result.Password);
- }
+ var mockTokenProvider = new Mock();
+ mockTokenProvider.Setup(x => x.Name).Returns("MSAL Managed Identity");
+ mockTokenProvider.Setup(x => x.IsInteractive).Returns(false);
+ mockTokenProvider.Setup(x => x.CanGetToken(It.IsAny())).Returns(true);
+ mockTokenProvider.Setup(x => x.GetTokenAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new AuthenticationResult(
+ token,
+ false,
+ null,
+ DateTimeOffset.MinValue,
+ DateTimeOffset.MaxValue,
+ null,
+ Mock.Of(),
+ null,
+ new List() { },
+ Guid.Empty));
+
+ return mockTokenProvider;
}
}
diff --git a/CredentialProvider.Microsoft.Tests/Util/FeedEndpointCredentialParserTests.cs b/CredentialProvider.Microsoft.Tests/Util/FeedEndpointCredentialParserTests.cs
new file mode 100644
index 00000000..9904caab
--- /dev/null
+++ b/CredentialProvider.Microsoft.Tests/Util/FeedEndpointCredentialParserTests.cs
@@ -0,0 +1,187 @@
+// Copyright (c) Microsoft. All rights reserved.
+//
+// Licensed under the MIT license.
+
+using System;
+using CredentialProvider.Microsoft.Tests.CredentialProviders.Vsts;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+using NuGetCredentialProvider.Util;
+using ILogger = NuGetCredentialProvider.Logging.ILogger;
+
+namespace CredentialProvider.Microsoft.Tests.Util;
+
+[TestClass]
+public class FeedEndpointCredentialParserTests
+{
+ private IDisposable environmentLock;
+ private Mock loggerMock;
+
+ public FeedEndpointCredentialParserTests()
+ {
+ environmentLock = EnvironmentLock.WaitAsync().Result;
+ loggerMock = new Mock();
+ }
+
+ [TestCleanup]
+ public virtual void TestCleanup()
+ {
+ Environment.SetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints, null);
+ Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, null);
+ environmentLock?.Dispose();
+ }
+
+ [TestMethod]
+ public void ParseFeedEndpointsJsonToDictionary_ReturnsCredentials()
+ {
+ string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"clientId\": \"testClientId\"}]}";
+ Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, feedEndPointJson);
+
+ var result = FeedEndpointCredentialsParser.ParseFeedEndpointsJsonToDictionary(loggerMock.Object);
+
+ result.Count.Should().Be(1);
+ result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].ClientId.Should().Be("testClientId");
+ }
+
+ [DataTestMethod]
+ [DataRow(null)]
+ [DataRow("")]
+ [DataRow(" ")]
+ [DataRow("invalid json")]
+ public void ParseFeedEndpointsJsonToDictionary_WhenInputInvalid_ReturnsEmpty(string invalidInput)
+ {
+ Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, invalidInput);
+
+ var result = FeedEndpointCredentialsParser.ParseFeedEndpointsJsonToDictionary(loggerMock.Object);
+
+ result.Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public void ParseFeedEndpointsJsonToDictionary_WithNoClientId_ReturnsEmpty()
+ {
+ string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\"}]}";
+ Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, feedEndPointJson);
+
+ var result = FeedEndpointCredentialsParser.ParseFeedEndpointsJsonToDictionary(loggerMock.Object);
+
+ result.Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public void ParseFeedEndpointsJsonToDictionary_WithCertificateFilePath_ReturnsCredentials()
+ {
+ string feedEndPointJson = @"{""endpointCredentials"":[{""endpoint"":""http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"", ""clientId"": ""testClientId"", ""clientCertificateFilePath"": ""test\\file\\path""}]}";
+ Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, feedEndPointJson);
+
+ var result = FeedEndpointCredentialsParser.ParseFeedEndpointsJsonToDictionary(loggerMock.Object);
+
+ result.Count.Should().Be(1);
+ result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].ClientId.Should().Be("testClientId");
+ result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].CertificateFilePath.Should().Be("test\\file\\path");
+ }
+
+
+ [TestMethod]
+ public void ParseFeedEndpointsJsonToDictionary_WithCertificateUnixFilePath_ReturnsCredentials()
+ {
+ string feedEndPointJson = @"{""endpointCredentials"":[{""endpoint"":""http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"", ""clientId"": ""testClientId"", ""clientCertificateFilePath"": ""test/file/path""}]}";
+ Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, feedEndPointJson);
+
+ var result = FeedEndpointCredentialsParser.ParseFeedEndpointsJsonToDictionary(loggerMock.Object);
+
+ result.Should().NotBeNull();
+ result.Count.Should().Be(1);
+ result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].ClientId.Should().Be("testClientId");
+ result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].CertificateFilePath.Should().Be("test/file/path");
+ }
+
+ [TestMethod]
+ public void ParseFeedEndpointsJsonToDictionary_WithSubjectName_ReturnsCredentials()
+ {
+ string feedEndPointJson = @"{""endpointCredentials"":[{""endpoint"":""http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"", ""clientId"": ""testClientId"", ""clientCertificateSubjectName"": ""someSubjectName""}]}";
+ Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, feedEndPointJson);
+
+ var result = FeedEndpointCredentialsParser.ParseFeedEndpointsJsonToDictionary(loggerMock.Object);
+
+ result.Count.Should().Be(1);
+ result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].ClientId.Should().Be("testClientId");
+ result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].CertificateSubjectName.Should().Be("someSubjectName");
+ }
+
+ [TestMethod]
+ public void ParseFeedEndpointsJsonToDictionary_WithCertificateUnixFilePathAndSubjectName_ReturnsEmpty()
+ {
+ string feedEndPointJson = @"{""endpointCredentials"":[{""endpoint"":""http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"", ""clientId"": ""testClientId"", ""clientCertificateFilePath"": ""test/file/path"", , ""clientCertificateSubjectName"": ""someSubjectName""}]}";
+ Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, feedEndPointJson);
+
+ var result = FeedEndpointCredentialsParser.ParseFeedEndpointsJsonToDictionary(loggerMock.Object);
+
+ result.Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public void ParseFeedEndpointsJsonToDictionary_WhenSingleQuotePresent_ReturnsEmpty()
+ {
+ string feedEndPointJson = "{'endpointCredentials':['endpoint':'http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json', 'clientId': 'testClientId'}]}";
+ Environment.SetEnvironmentVariable(EnvUtil.EndpointCredentials, feedEndPointJson);
+
+ var result = FeedEndpointCredentialsParser.ParseFeedEndpointsJsonToDictionary(loggerMock.Object);
+
+ result.Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public void ParseExternalFeedEndpointsJsonToDictionary_ReturnsCredentials()
+ {
+ string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"username\": \"testuser\", \"password\": \"testPassword\"}]}";
+ Environment.SetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints, feedEndPointJson);
+
+ var result = FeedEndpointCredentialsParser.ParseExternalFeedEndpointsJsonToDictionary(loggerMock.Object);
+
+ result.Count.Should().Be(1);
+ result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].Username.Should().Be("testuser");
+ result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].Password.Should().Be("testPassword");
+ }
+
+ [TestMethod]
+ public void ParseExternalFeedEndpointsJsonToDictionary_WithoutUserName_ReturnsCredentials()
+ {
+ string feedEndPointJson = "{\"endpointCredentials\":[{\"endpoint\":\"http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\", \"password\": \"testPassword\"}]}";
+ Environment.SetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints, feedEndPointJson);
+
+ var result = FeedEndpointCredentialsParser.ParseExternalFeedEndpointsJsonToDictionary(loggerMock.Object);
+
+ result.Count.Should().Be(1);
+ result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].Username.Should().Be("VssSessionToken");
+ result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].Password.Should().Be("testPassword");
+ }
+
+ [TestMethod]
+ public void ParseExternalFeedEndpointsJsonToDictionary_WithSingleQuotes_ReturnsCredentials()
+ {
+ string feedEndPointJson = "{\'endpointCredentials\':[{\'endpoint\':\'http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json\', \'username\': \'testuser\', \'password\': \'testPassword\'}]}";
+ Environment.SetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints, feedEndPointJson);
+
+ var result = FeedEndpointCredentialsParser.ParseExternalFeedEndpointsJsonToDictionary(loggerMock.Object);
+
+ result.Count.Should().Be(1);
+ result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].Username.Should().Be("testuser");
+ result["http://example.pkgs.vsts.me/_packaging/TestFeed/nuget/v3/index.json"].Password.Should().Be("testPassword");
+ }
+
+ [DataTestMethod]
+ [DataRow(null)]
+ [DataRow("")]
+ [DataRow(" ")]
+ [DataRow("invalid json")]
+ public void ParseFeedEndpointsJsonToDictionary_WhenInvalidInput_ReturnsEmpty(string input)
+ {
+ Environment.SetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints, input);
+
+ var result = FeedEndpointCredentialsParser.ParseExternalFeedEndpointsJsonToDictionary(loggerMock.Object);
+
+ result.Should().BeEmpty();
+ }
+}
diff --git a/CredentialProvider.Microsoft/CredentialProviders/Vsts/IAuthUtil.cs b/CredentialProvider.Microsoft/CredentialProviders/Vsts/IAuthUtil.cs
index fbdebe3f..5fe38921 100644
--- a/CredentialProvider.Microsoft/CredentialProviders/Vsts/IAuthUtil.cs
+++ b/CredentialProvider.Microsoft/CredentialProviders/Vsts/IAuthUtil.cs
@@ -16,7 +16,7 @@ namespace NuGetCredentialProvider.CredentialProviders.Vsts
{
public interface IAuthUtil
{
- Task GetAadAuthorityUriAsync(Uri uri, CancellationToken cancellationToken);
+ Task GetAuthorizationInfoAsync(Uri uri, CancellationToken cancellationToken);
Task GetAzDevDeploymentType(Uri uri);
@@ -30,6 +30,12 @@ public enum AzDevDeploymentType
OnPrem
}
+ public struct AuthorizationInfo
+ {
+ public Uri EntraAuthorityUri { get; set; }
+ public string EntraTenantId { get; set; }
+ };
+
public class AuthUtil : IAuthUtil
{
public const string VssResourceTenant = "X-VSS-ResourceTenant";
@@ -44,47 +50,15 @@ public AuthUtil(ILogger logger)
this.logger = logger;
}
- public async Task GetAadAuthorityUriAsync(Uri uri, CancellationToken cancellationToken)
+ public async Task GetAuthorizationInfoAsync(Uri uri, CancellationToken cancellationToken)
{
- var environmentAuthority = EnvUtil.GetAuthorityFromEnvironment(logger);
- if (environmentAuthority != null)
- {
- return environmentAuthority;
- }
-
var headers = await GetResponseHeadersAsync(uri, cancellationToken);
- var bearerHeaders = headers.WwwAuthenticate.Where(x => x.Scheme.Equals("Bearer", StringComparison.Ordinal));
- foreach (var param in bearerHeaders)
+ return new AuthorizationInfo
{
- if (param.Parameter == null)
- {
- // MSA-backed accounts don't expose a parameter
- continue;
- }
-
- var equalSplit = param.Parameter.Split(new[] { "=" }, StringSplitOptions.RemoveEmptyEntries);
- if (equalSplit.Length == 2)
- {
- if (equalSplit[0].Equals("authorization_uri", StringComparison.OrdinalIgnoreCase))
- {
- if (Uri.TryCreate(equalSplit[1], UriKind.Absolute, out Uri parsedUri))
- {
- logger.Verbose(string.Format(Resources.FoundAADAuthorityFromHeaders, parsedUri));
- return parsedUri;
- }
- }
- }
- }
-
- // Return the common tenant
- var aadBase = UsePpeAadUrl(uri) ? "https://login.windows-ppe.net" : "https://login.microsoftonline.com";
- logger.Verbose(string.Format(Resources.AADAuthorityNotFound, aadBase));
-
- // The Azure Artifacts application has MSA-Passthrough enabled which requires the use of the organizations
- // tenant when requesting tokens for MSA users. This covers both organizations and consumers in cases where
- // a tenant ID cannot be obtained from authenticate headers.
- return new Uri($"{aadBase}/organizations");
+ EntraAuthorityUri = GetAuthority(uri, headers),
+ EntraTenantId = GetTenantId(headers),
+ };
}
public async Task GetAzDevDeploymentType(Uri uri)
@@ -152,6 +126,59 @@ protected virtual async Task GetResponseHeadersAsync(Uri ur
}
}
+ private Uri GetAuthority(Uri uri, HttpResponseHeaders responseHeaders)
+ {
+ var environmentAuthority = EnvUtil.GetAuthorityFromEnvironment(logger);
+ if (environmentAuthority != null)
+ {
+ return environmentAuthority;
+ }
+
+ var bearerHeaders = responseHeaders.WwwAuthenticate.Where(x => x.Scheme.Equals("Bearer", StringComparison.Ordinal));
+
+ foreach (var param in bearerHeaders)
+ {
+ if (param.Parameter == null)
+ {
+ // MSA-backed accounts don't expose a parameter
+ continue;
+ }
+
+ var equalSplit = param.Parameter.Split(new[] { "=" }, StringSplitOptions.RemoveEmptyEntries);
+ if (equalSplit.Length == 2)
+ {
+ if (equalSplit[0].Equals("authorization_uri", StringComparison.OrdinalIgnoreCase))
+ {
+ if (Uri.TryCreate(equalSplit[1], UriKind.Absolute, out Uri parsedUri))
+ {
+ logger.Verbose(string.Format(Resources.FoundAADAuthorityFromHeaders, parsedUri));
+ return parsedUri;
+ }
+ }
+ }
+ }
+
+ // Return the common tenant
+ var aadBase = UsePpeAadUrl(uri) ? "https://login.windows-ppe.net" : "https://login.microsoftonline.com";
+ logger.Verbose(string.Format(Resources.AADAuthorityNotFound, aadBase));
+
+ // The Azure Artifacts application has MSA-Passthrough enabled which requires the use of the organizations
+ // tenant when requesting tokens for MSA users. This covers both organizations and consumers in cases where
+ // a tenant ID cannot be obtained from authenticate headers.
+ return new Uri($"{aadBase}/organizations");
+ }
+
+ private string GetTenantId(HttpResponseHeaders responseHeaders)
+ {
+ if (responseHeaders.Contains(VssResourceTenant))
+ {
+ responseHeaders.TryGetValues(VssResourceTenant, out var tenantId);
+ return tenantId.FirstOrDefault();
+ }
+
+ return null;
+ }
+
private bool UsePpeAadUrl(Uri uri)
{
var ppeHosts = EnvUtil.GetHostsFromEnvironment(logger, EnvUtil.PpeHostsEnvVar, new[]
diff --git a/CredentialProvider.Microsoft/CredentialProviders/Vsts/VstsCredentialProvider.cs b/CredentialProvider.Microsoft/CredentialProviders/Vsts/VstsCredentialProvider.cs
index 6bd9eb27..56904823 100644
--- a/CredentialProvider.Microsoft/CredentialProviders/Vsts/VstsCredentialProvider.cs
+++ b/CredentialProvider.Microsoft/CredentialProviders/Vsts/VstsCredentialProvider.cs
@@ -41,11 +41,15 @@ public override async Task CanProvideCredentialsAsync(Uri uri)
{
// If for any reason we reach this point and any of the three build task env vars are set,
// we should not try get credentials with this cred provider.
- string feedEndPointsJsonEnvVar = Environment.GetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints);
+ string feedEndPointsJsonEnvVar = Environment.GetEnvironmentVariable(EnvUtil.EndpointCredentials);
+ string externalFeedEndPointsJsonEnvVar = Environment.GetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints);
string uriPrefixesStringEnvVar = Environment.GetEnvironmentVariable(EnvUtil.BuildTaskUriPrefixes);
string accessTokenEnvVar = Environment.GetEnvironmentVariable(EnvUtil.BuildTaskAccessToken);
- if (string.IsNullOrWhiteSpace(feedEndPointsJsonEnvVar) == false || string.IsNullOrWhiteSpace(uriPrefixesStringEnvVar) == false || string.IsNullOrWhiteSpace(accessTokenEnvVar) == false)
+ if (string.IsNullOrWhiteSpace(feedEndPointsJsonEnvVar) == false ||
+ string.IsNullOrWhiteSpace(externalFeedEndPointsJsonEnvVar) == false ||
+ string.IsNullOrWhiteSpace(uriPrefixesStringEnvVar) == false ||
+ string.IsNullOrWhiteSpace(accessTokenEnvVar) == false)
{
Verbose(Resources.BuildTaskCredProviderIsUsedError);
return false;
@@ -96,10 +100,10 @@ public override async Task HandleRequestAs
canShowDialog = forceCanShowDialogTo.Value;
}
- Uri authority = await authUtil.GetAadAuthorityUriAsync(request.Uri, cancellationToken);
- Verbose(string.Format(Resources.UsingAuthority, authority));
+ var authInfo = await authUtil.GetAuthorizationInfoAsync(request.Uri, cancellationToken);
+ Verbose(string.Format(Resources.UsingAuthority, authInfo.EntraAuthorityUri));
- IEnumerable tokenProviders = await tokenProvidersFactory.GetAsync(authority);
+ IEnumerable tokenProviders = await tokenProvidersFactory.GetAsync(authInfo.EntraAuthorityUri);
cancellationToken.ThrowIfCancellationRequested();
var tokenRequest = new TokenRequest(request.Uri)
@@ -116,7 +120,7 @@ public override async Task HandleRequestAs
Logger.Minimal(string.Format(Resources.DeviceFlowMessage, deviceCodeResult.VerificationUrl, deviceCodeResult.UserCode));
return Task.CompletedTask;
- }
+ },
};
// Try each bearer token provider (e.g. cache, WIA, UI, DeviceCode) in order.
diff --git a/CredentialProvider.Microsoft/CredentialProviders/Vsts/VstsSessionTokenClient.cs b/CredentialProvider.Microsoft/CredentialProviders/Vsts/VstsSessionTokenClient.cs
index 94ce5522..9cd11085 100644
--- a/CredentialProvider.Microsoft/CredentialProviders/Vsts/VstsSessionTokenClient.cs
+++ b/CredentialProvider.Microsoft/CredentialProviders/Vsts/VstsSessionTokenClient.cs
@@ -6,9 +6,9 @@
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
+using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
-using Newtonsoft.Json;
using NuGetCredentialProvider.Logging;
using NuGetCredentialProvider.Util;
@@ -18,6 +18,12 @@ public class VstsSessionTokenClient : IVstsSessionTokenClient
{
private const string TokenScope = "vso.packaging_write vso.drop_write";
+ private static readonly JsonSerializerOptions options = new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true
+ };
+
private readonly Uri vstsUri;
private readonly string bearerToken;
private readonly IAuthUtil authUtil;
@@ -45,7 +51,7 @@ private HttpRequestMessage CreateRequest(Uri uri, DateTime? validTo)
};
request.Content = new StringContent(
- JsonConvert.SerializeObject(tokenRequest),
+ JsonSerializer.Serialize(tokenRequest, options),
Encoding.UTF8,
"application/json");
@@ -77,7 +83,6 @@ public async Task CreateSessionTokenAsync(VstsTokenType tokenType, DateT
string serializedResponse;
if (response.StatusCode == System.Net.HttpStatusCode.BadRequest)
{
-
request.Dispose();
response.Dispose();
@@ -97,7 +102,7 @@ public async Task CreateSessionTokenAsync(VstsTokenType tokenType, DateT
serializedResponse = await response.Content.ReadAsStringAsync();
}
- var responseToken = JsonConvert.DeserializeObject(serializedResponse);
+ var responseToken = JsonSerializer.Deserialize(serializedResponse, options);
if (validTo.Subtract(responseToken.ValidTo.Value).TotalHours > 1.0)
{
diff --git a/CredentialProvider.Microsoft/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskMsalTokenProvidersFactory.cs b/CredentialProvider.Microsoft/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskMsalTokenProvidersFactory.cs
new file mode 100644
index 00000000..2cdf1c92
--- /dev/null
+++ b/CredentialProvider.Microsoft/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskMsalTokenProvidersFactory.cs
@@ -0,0 +1,47 @@
+// Copyright (c) Microsoft. All rights reserved.
+//
+// Licensed under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Microsoft.Artifacts.Authentication;
+using Microsoft.Extensions.Logging;
+using Microsoft.Identity.Client;
+using NuGetCredentialProvider.Util;
+
+namespace NuGetCredentialProvider.CredentialProviders.VstsBuildTaskServiceEndpoint;
+
+internal class VstsBuildTaskMsalTokenProvidersFactory : ITokenProvidersFactory
+{
+ private readonly ILogger logger;
+
+ public VstsBuildTaskMsalTokenProvidersFactory(ILogger logger)
+ {
+ this.logger = logger;
+ }
+
+ public Task> GetAsync(Uri authority)
+ {
+ var app = AzureArtifacts.CreateDefaultBuilder(authority)
+ .WithBroker(EnvUtil.MsalAllowBrokerEnabled(), logger)
+ .WithHttpClientFactory(HttpClientFactory.Default)
+ .WithLogging(
+ (level, message, containsPii) =>
+ {
+ // We ignore containsPii param because we are passing in enablePiiLogging below.
+ logger.LogTrace("MSAL Log ({level}): {message}", level, message);
+ },
+ enablePiiLogging: EnvUtil.GetLogPIIEnabled()
+ )
+ .Build();
+
+ return Task.FromResult(ConstructTokenProvidersList(app));
+ }
+
+ private IEnumerable ConstructTokenProvidersList(IPublicClientApplication app)
+ {
+ yield return new MsalServicePrincipalTokenProvider(app, logger);
+ yield return new MsalManagedIdentityTokenProvider(app, logger);
+ }
+}
diff --git a/CredentialProvider.Microsoft/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProvider.cs b/CredentialProvider.Microsoft/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProvider.cs
index 8b551a55..a9c3fc28 100644
--- a/CredentialProvider.Microsoft/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProvider.cs
+++ b/CredentialProvider.Microsoft/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProvider.cs
@@ -4,45 +4,41 @@
using System;
using System.Collections.Generic;
+using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
-using Newtonsoft.Json;
+using Microsoft.Artifacts.Authentication;
using NuGet.Protocol.Plugins;
+using NuGetCredentialProvider.CredentialProviders.Vsts;
using NuGetCredentialProvider.Util;
using ILogger = NuGetCredentialProvider.Logging.ILogger;
namespace NuGetCredentialProvider.CredentialProviders.VstsBuildTaskServiceEndpoint
{
- public class EndpointCredentials
- {
- [JsonProperty("endpoint")]
- public string Endpoint { get; set; }
- [JsonProperty("username")]
- public string Username { get; set; }
- [JsonProperty("password")]
- public string Password { get; set; }
- }
-
- public class EndpointCredentialsContainer
- {
- [JsonProperty("endpointCredentials")]
- public EndpointCredentials[] EndpointCredentials { get; set; }
- }
-
public sealed class VstsBuildTaskServiceEndpointCredentialProvider : CredentialProviderBase
{
private Lazy> LazyCredentials;
+ private Lazy> LazyExternalCredentials;
+ private ITokenProvidersFactory TokenProvidersFactory;
+ private IAuthUtil AuthUtil;
// Dictionary that maps an endpoint string to EndpointCredentials
private Dictionary Credentials => LazyCredentials.Value;
-
- public VstsBuildTaskServiceEndpointCredentialProvider(ILogger logger)
+ private Dictionary ExternalCredentials => LazyExternalCredentials.Value;
+
+ public VstsBuildTaskServiceEndpointCredentialProvider(ILogger logger, ITokenProvidersFactory tokenProvidersFactory, IAuthUtil authUtil)
: base(logger)
{
+ TokenProvidersFactory = tokenProvidersFactory;
LazyCredentials = new Lazy>(() =>
{
- return ParseJsonToDictionary();
+ return FeedEndpointCredentialsParser.ParseFeedEndpointsJsonToDictionary(logger);
});
+ LazyExternalCredentials = new Lazy>(() =>
+ {
+ return FeedEndpointCredentialsParser.ParseExternalFeedEndpointsJsonToDictionary(logger);
+ });
+ AuthUtil = authUtil;
}
public override bool IsCachable { get { return false; } }
@@ -51,8 +47,10 @@ public VstsBuildTaskServiceEndpointCredentialProvider(ILogger logger)
public override Task CanProvideCredentialsAsync(Uri uri)
{
- string feedEndPointsJson = Environment.GetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints);
- if (string.IsNullOrWhiteSpace(feedEndPointsJson))
+ string feedEndPointsJson = Environment.GetEnvironmentVariable(EnvUtil.EndpointCredentials);
+ string externalFeedEndPointsJson = Environment.GetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints);
+
+ if (string.IsNullOrWhiteSpace(feedEndPointsJson) && string.IsNullOrWhiteSpace(externalFeedEndPointsJson))
{
Verbose(Resources.BuildTaskEndpointEnvVarError);
return Task.FromResult(false);
@@ -61,24 +59,89 @@ public override Task CanProvideCredentialsAsync(Uri uri)
return Task.FromResult(true);
}
- public override Task HandleRequestAsync(GetAuthenticationCredentialsRequest request, CancellationToken cancellationToken)
+ public override async Task HandleRequestAsync(GetAuthenticationCredentialsRequest request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
Verbose(string.Format(Resources.IsRetry, request.IsRetry));
string uriString = request.Uri.AbsoluteUri;
- bool endpointFound = Credentials.TryGetValue(uriString, out EndpointCredentials matchingEndpoint);
- if (endpointFound)
+ bool externalEndpointFound = ExternalCredentials.TryGetValue(uriString, out ExternalEndpointCredentials matchingExternalEndpoint);
+ if (externalEndpointFound && !string.IsNullOrWhiteSpace(matchingExternalEndpoint.Password))
{
Verbose(string.Format(Resources.BuildTaskEndpointMatchingUrlFound, uriString));
return GetResponse(
- matchingEndpoint.Username,
- matchingEndpoint.Password,
+ matchingExternalEndpoint.Username,
+ matchingExternalEndpoint.Password,
null,
MessageResponseCode.Success);
}
+ bool endpointFound = Credentials.TryGetValue(uriString, out EndpointCredentials matchingEndpoint);
+ if (endpointFound && !string.IsNullOrWhiteSpace(matchingEndpoint.ClientId))
+ {
+ var authInfo = await AuthUtil.GetAuthorizationInfoAsync(request.Uri, cancellationToken);
+ Verbose(string.Format(Resources.UsingAuthority, authInfo.EntraAuthorityUri));
+ Verbose(string.Format(Resources.UsingTenant, authInfo.EntraTenantId));
+
+ var clientCertificate = GetCertificate(matchingEndpoint);
+ Info(clientCertificate == null
+ ? (Resources.ClientCertificateNotFound)
+ : string.Format(Resources.UsingCertificate, clientCertificate.Subject));
+
+ IEnumerable tokenProviders = await TokenProvidersFactory.GetAsync(authInfo.EntraAuthorityUri);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var tokenRequest = new TokenRequest(request.Uri)
+ {
+ IsRetry = request.IsRetry,
+ IsNonInteractive = true,
+ CanShowDialog = false,
+ IsWindowsIntegratedAuthEnabled = false,
+ InteractiveTimeout = TimeSpan.FromSeconds(EnvUtil.GetDeviceFlowTimeoutFromEnvironmentInSeconds(Logger)),
+ ClientId = matchingEndpoint.ClientId,
+ ClientCertificate = clientCertificate,
+ TenantId = authInfo.EntraTenantId
+ };
+
+ foreach(var tokenProvider in tokenProviders)
+ {
+ bool shouldRun = tokenProvider.CanGetToken(tokenRequest);
+ if (!shouldRun)
+ {
+ Verbose(string.Format(Resources.NotRunningBearerTokenProvider, tokenProvider.Name));
+ continue;
+ }
+
+ Verbose(string.Format(Resources.AttemptingToAcquireBearerTokenUsingProvider, tokenProvider.Name));
+
+ string bearerToken;
+ try
+ {
+ var result = await tokenProvider.GetTokenAsync(tokenRequest, cancellationToken);
+ bearerToken = result?.AccessToken;
+ }
+ catch (Exception ex)
+ {
+ Verbose(string.Format(Resources.BearerTokenProviderException, tokenProvider.Name, ex));
+ continue;
+ }
+
+ if (string.IsNullOrWhiteSpace(bearerToken))
+ {
+ Verbose(string.Format(Resources.BearerTokenProviderReturnedNull, tokenProvider.Name));
+ continue;
+ }
+
+ Info(string.Format(Resources.AcquireBearerTokenSuccess, tokenProvider.Name));
+ return GetResponse(
+ matchingEndpoint.ClientId,
+ bearerToken,
+ null,
+ MessageResponseCode.Success);
+ }
+ }
+
Verbose(string.Format(Resources.BuildTaskEndpointNoMatchingUrl, uriString));
return GetResponse(
null,
@@ -87,9 +150,9 @@ public override Task HandleRequestAsync(Ge
MessageResponseCode.Error);
}
- private Task GetResponse(string username, string password, string message, MessageResponseCode responseCode)
+ private GetAuthenticationCredentialsResponse GetResponse(string username, string password, string message, MessageResponseCode responseCode)
{
- return Task.FromResult(new GetAuthenticationCredentialsResponse(
+ return new GetAuthenticationCredentialsResponse(
username: username,
password: password,
message: message,
@@ -97,62 +160,22 @@ private Task GetResponse(string username,
{
"Basic"
},
- responseCode: responseCode));
+ responseCode: responseCode);
}
- private Dictionary ParseJsonToDictionary()
+ private X509Certificate2 GetCertificate(EndpointCredentials credentials)
{
- string feedEndPointsJson = Environment.GetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints);
-
- try
+ if (!string.IsNullOrWhiteSpace(credentials.CertificateSubjectName))
{
- // Parse JSON from VSS_NUGET_EXTERNAL_FEED_ENDPOINTS
- Verbose(Resources.ParsingJson);
- if (!string.IsNullOrWhiteSpace(feedEndPointsJson) && feedEndPointsJson.Contains("':"))
- {
- Warning(Resources.InvalidJsonWarning);
- }
- Dictionary credsResult = new Dictionary(StringComparer.OrdinalIgnoreCase);
- EndpointCredentialsContainer endpointCredentials = JsonConvert.DeserializeObject(feedEndPointsJson);
- if (endpointCredentials == null)
- {
- Verbose(Resources.NoEndpointsFound);
- return credsResult;
- }
-
- foreach (EndpointCredentials credentials in endpointCredentials.EndpointCredentials)
- {
- if (credentials == null)
- {
- Verbose(Resources.EndpointParseFailure);
- break;
- }
-
- if (credentials.Username == null)
- {
- credentials.Username = "VssSessionToken";
- }
-
- if (!Uri.TryCreate(credentials.Endpoint, UriKind.Absolute, out var endpointUri))
- {
- Verbose(Resources.EndpointParseFailure);
- break;
- }
-
- var urlEncodedEndpoint = endpointUri.AbsoluteUri;
- if (!credsResult.ContainsKey(urlEncodedEndpoint))
- {
- credsResult.Add(urlEncodedEndpoint, credentials);
- }
- }
-
- return credsResult;
+ return CertificateUtil.GetCertificateBySubjectName(Logger, credentials.CertificateSubjectName);
}
- catch (Exception e)
+
+ if (!string.IsNullOrWhiteSpace(credentials.CertificateFilePath))
{
- Verbose(string.Format(Resources.VstsBuildTaskExternalCredentialCredentialProviderError, e));
- throw;
+ return CertificateUtil.GetCertificateByFilePath(Logger, credentials.CertificateFilePath);
}
+
+ return null;
}
}
}
diff --git a/CredentialProvider.Microsoft/Program.cs b/CredentialProvider.Microsoft/Program.cs
index 2ba1a84d..3059fcb3 100644
--- a/CredentialProvider.Microsoft/Program.cs
+++ b/CredentialProvider.Microsoft/Program.cs
@@ -5,10 +5,10 @@
using System;
using System.Collections.Generic;
using System.IO;
+using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Artifacts.Authentication;
-using Newtonsoft.Json;
using NuGet.Common;
using NuGet.Protocol.Plugins;
using NuGetCredentialProvider.CredentialProviders;
@@ -53,11 +53,12 @@ public static async Task Main(string[] args)
var authUtil = new AuthUtil(multiLogger);
var logger = new NuGetLoggerAdapter(multiLogger, parsedArgs.Verbosity);
var tokenProvidersFactory = new MsalTokenProvidersFactory(logger);
+ var vstsBuildTaskTokenProvidersFactory = new VstsBuildTaskMsalTokenProvidersFactory(logger);
var vstsSessionTokenProvider = new VstsSessionTokenFromBearerTokenProvider(authUtil, multiLogger);
List credentialProviders = new List
{
- new VstsBuildTaskServiceEndpointCredentialProvider(multiLogger),
+ new VstsBuildTaskServiceEndpointCredentialProvider(multiLogger, vstsBuildTaskTokenProvidersFactory, authUtil),
new VstsBuildTaskCredentialProvider(multiLogger),
new VstsCredentialProvider(multiLogger, authUtil, tokenProvidersFactory, vstsSessionTokenProvider),
};
@@ -89,6 +90,7 @@ public static async Task Main(string[] args)
EnvUtil.BuildTaskUriPrefixes,
EnvUtil.BuildTaskAccessToken,
EnvUtil.BuildTaskExternalEndpoints,
+ EnvUtil.EndpointCredentials,
EnvUtil.DefaultMsalCacheLocation,
EnvUtil.SessionTokenCacheLocation,
EnvUtil.WindowsIntegratedAuthenticationEnvVar,
@@ -165,7 +167,7 @@ public static async Task Main(string[] args)
if (parsedArgs.OutputFormat == OutputFormat.Json)
{
// Manually write the JSON output, since we don't use ConsoleLogger in JSON mode (see above)
- Console.WriteLine(JsonConvert.SerializeObject(new CredentialResult(resultUsername, resultPassword)));
+ Console.WriteLine(JsonSerializer.Serialize(new CredentialResult(resultUsername, resultPassword)));
}
else
{
diff --git a/CredentialProvider.Microsoft/Resources.resx b/CredentialProvider.Microsoft/Resources.resx
index b05182d8..b71a2873 100644
--- a/CredentialProvider.Microsoft/Resources.resx
+++ b/CredentialProvider.Microsoft/Resources.resx
@@ -118,7 +118,7 @@
System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
- Could not find AAD authority from headers, using default: {0}
+ Could not find Entra authority from headers, using default: {0}
Found AAD authority override: {0}
@@ -140,7 +140,7 @@
DeviceFlow: {0}
- Using AAD authority: {0}
+ Using Entra authority: {0}
This credential provider must be run under the Team Build tasks for NuGet. Appropriate environment variables must be set.
@@ -167,7 +167,7 @@
Command-line v{0}: {1}
- Could not parse AAD authority override: {0}
+ Could not parse Entra authority override: {0}
Could not parse Session Time override: {0}
@@ -311,42 +311,51 @@ Build Provider Service Endpoint Json
Example: {{"endpointCredentials": [{{"endpoint":"http://example.index.json",
"username":"optional", "password":"accesstoken"}}]}}
+Artifacts Service Endpoint Json
+ {8}
+ Json that contains an array of service endpoints, client ids, and certificate
+ information to authenticate endpoints for Azure Managed Identities
+ and Service Principals.
+ Example: {{"endpointCredentials": [{{"endpoint":"http://example.index.json",
+ "clientId":"required", "clientCertificateFilePath":"optional","
+ clientCertificateSubjectName": "optional" }}]}}
+
Cache Location
The Credential Provider uses the following paths to cache credentials. If
deleted, the credential provider will re-create them but any credentials
will need to be provided again.
MSAL Token Cache
- {8}
+ {9}
Session Token Cache
- {9}
+ {10}
Windows Integrated Authentication
- {10}
+ {11}
Boolean to enable/disable using silent Windows Integrated Authentication
to authenticate as the logged-in user. Enabled by default.
Device Flow Authentication Timeout
- {11}
+ {12}
Device Flow authentication timeout in seconds. Default is 90 seconds.
NuGet workarounds
- {12}
+ {13}
Set to "true" or "false" to override any other sources of the
CanShowDialog parameter.
MSAL Authority
- {13}
+ {14}
Set to override the authority used when fetching an MSAL token.
e.g. https://login.microsoftonline.com/organizations
MSAL Token File Cache Enabled
- {14}
+ {15}
Boolean to enable/disable the MSAL token cache. Enabled by default.
Provide MSAL Cache Location
- {15}
+ {16}
Provide the location where the MSAL cache should be read and written to.
@@ -398,7 +407,7 @@ Provide MSAL Cache Location
Could not parse Device Flow Timeout override: {0}
- This credential provider must not run if any build task environment variables are set. Variables: VSS_NUGET_URI_PREFIXES, VSS_NUGET_ACCESSTOKEN, VSS_NUGET_EXTERNAL_FEED_ENDPOINTS
+ This credential provider must not run if any build task environment variables are set. Variables: VSS_NUGET_URI_PREFIXES, VSS_NUGET_ACCESSTOKEN, VSS_NUGET_EXTERNAL_FEED_ENDPOINTS, ARTIFACTS_CREDENTIALPROVIDER_FEED_ENDPOINTS
Environment variable VSS_NUGET_EXTERNAL_FEED_ENDPOINTS did not have credentials for endpoint {0}
@@ -475,6 +484,29 @@ Provide MSAL Cache Location
Detected invalid single quote charater in JSON input. Migrate to double quotes to avoid breaking in future versions. See https://www.rfc-editor.org/rfc/rfc8259.html#section-7 for more information.
-
+
+
+ Error accessing client certificate. Exception {0}, Message: {1}
+
+
+ Certificate with file path {0} not found.
+
+
+ Found client certificate for {0}.
+
+
+ Unable to find client certificate.
+
+
+ Certificate with subject name {0} not found.
+
+
+ Certificate information invalid.
+
+
+ Using certificate: {0}.
+
+
+ Using Entra tenant: {0}.
\ No newline at end of file
diff --git a/CredentialProvider.Microsoft/Util/CertificateUtil.cs b/CredentialProvider.Microsoft/Util/CertificateUtil.cs
new file mode 100644
index 00000000..0c5745be
--- /dev/null
+++ b/CredentialProvider.Microsoft/Util/CertificateUtil.cs
@@ -0,0 +1,74 @@
+using System;
+using System.Security.Cryptography.X509Certificates;
+using ILogger = NuGetCredentialProvider.Logging.ILogger;
+
+namespace NuGetCredentialProvider.Util;
+
+internal static class CertificateUtil
+{
+ public static X509Certificate2 GetCertificateBySubjectName(ILogger logger, string subjectName)
+ {
+ if (string.IsNullOrWhiteSpace(subjectName))
+ {
+ logger.Info(message: Resources.InvalidCertificateInput);
+ return null;
+ }
+
+ var locations = new []{ StoreLocation.CurrentUser, StoreLocation.LocalMachine };
+ foreach (var location in locations)
+ {
+ var store = new X509Store(StoreName.My, location);
+ try
+ {
+ store.Open(OpenFlags.ReadOnly);
+ var cert = store.Certificates.Find(X509FindType.FindBySubjectDistinguishedName , subjectName, false);
+
+ if (cert.Count > 0)
+ {
+ logger.Verbose(string.Format(Resources.ClientCertificateFound, subjectName));
+ return cert[0];
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.Error(string.Format(Resources.ClientCertificateError, ex, ex.Message));
+ continue;
+ }
+ finally
+ {
+ store.Close();
+ }
+ }
+
+ logger.Info(string.Format(Resources.ClientCertificateSubjectNameNotFound, subjectName));
+ return null;
+ }
+
+ public static X509Certificate2 GetCertificateByFilePath(ILogger logger, string filePath)
+ {
+ if (string.IsNullOrWhiteSpace(filePath))
+ {
+ logger.Info(message: Resources.InvalidCertificateInput);
+ return null;
+ }
+
+ try
+ {
+ var certificate = new X509Certificate2(filePath);
+
+ if (certificate == null)
+ {
+ logger.Verbose(string.Format(Resources.ClientCertificateFilePathNotFound, filePath));
+ return null;
+ }
+
+ logger.Verbose(string.Format(Resources.ClientCertificateFound, filePath));
+ return certificate;
+ }
+ catch (Exception ex)
+ {
+ logger.Error(string.Format(Resources.ClientCertificateError, ex, ex.Message));
+ return null;
+ }
+ }
+}
diff --git a/CredentialProvider.Microsoft/Util/EnvUtil.cs b/CredentialProvider.Microsoft/Util/EnvUtil.cs
index 8a05635e..3d310329 100644
--- a/CredentialProvider.Microsoft/Util/EnvUtil.cs
+++ b/CredentialProvider.Microsoft/Util/EnvUtil.cs
@@ -29,7 +29,6 @@ public static class EnvUtil
public const string BuildTaskUriPrefixes = "VSS_NUGET_URI_PREFIXES";
public const string BuildTaskAccessToken = "VSS_NUGET_ACCESSTOKEN";
- public const string BuildTaskExternalEndpoints = "VSS_NUGET_EXTERNAL_FEED_ENDPOINTS";
public const string MsalLoginHintEnvVar = "NUGET_CREDENTIALPROVIDER_MSAL_LOGIN_HINT";
public const string MsalAuthorityEnvVar = "NUGET_CREDENTIALPROVIDER_MSAL_AUTHORITY";
@@ -37,6 +36,9 @@ public static class EnvUtil
public const string MsalFileCacheLocationEnvVar = "NUGET_CREDENTIALPROVIDER_MSAL_FILECACHE_LOCATION";
public const string MsalAllowBrokerEnvVar = "NUGET_CREDENTIALPROVIDER_MSAL_ALLOW_BROKER";
+ public const string EndpointCredentials = "ARTIFACTS_CREDENTIALPROVIDER_FEED_ENDPOINTS";
+ public const string BuildTaskExternalEndpoints = "VSS_NUGET_EXTERNAL_FEED_ENDPOINTS";
+
public static bool GetLogPIIEnabled()
{
return GetEnabledFromEnvironment(LogPIIEnvVar, defaultValue: false);
diff --git a/CredentialProvider.Microsoft/Util/FeedEndpointCredentialsParser.cs b/CredentialProvider.Microsoft/Util/FeedEndpointCredentialsParser.cs
new file mode 100644
index 00000000..3487bc0e
--- /dev/null
+++ b/CredentialProvider.Microsoft/Util/FeedEndpointCredentialsParser.cs
@@ -0,0 +1,170 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Newtonsoft.Json;
+using ILogger = NuGetCredentialProvider.Logging.ILogger;
+
+namespace NuGetCredentialProvider.Util;
+public class ExternalEndpointCredentials
+{
+ [JsonProperty("endpoint")]
+ public string Endpoint { get; set; }
+ [JsonProperty("username")]
+ public string Username { get; set; }
+ [JsonProperty("password")]
+ public string Password { get; set; }
+}
+
+public class ExternalEndpointCredentialsContainer
+{
+ [JsonProperty("endpointCredentials")]
+ public ExternalEndpointCredentials[] EndpointCredentials { get; set; }
+}
+
+public class EndpointCredentials
+{
+ [JsonPropertyName("endpoint")]
+ public string Endpoint { get; set; }
+ [JsonPropertyName("clientId")]
+ public string ClientId { get; set; }
+ [JsonPropertyName("clientCertificateFilePath")]
+ public string CertificateFilePath { get; set; }
+ [JsonPropertyName("clientCertificateSubjectName")]
+ public string CertificateSubjectName { get; set; }
+}
+
+public class EndpointCredentialsContainer
+{
+ [JsonPropertyName("endpointCredentials")]
+ public EndpointCredentials[] EndpointCredentials { get; set; }
+}
+
+public static class FeedEndpointCredentialsParser
+{
+ private static readonly JsonSerializerOptions options = new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true,
+ };
+
+ public static Dictionary ParseFeedEndpointsJsonToDictionary(ILogger logger)
+ {
+ string feedEndpointsJson = Environment.GetEnvironmentVariable(EnvUtil.EndpointCredentials);
+ if (string.IsNullOrWhiteSpace(feedEndpointsJson))
+ {
+ return new Dictionary(StringComparer.OrdinalIgnoreCase);
+ }
+
+ try
+ {
+ logger.Verbose(Resources.ParsingJson);
+ Dictionary credsResult = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ EndpointCredentialsContainer endpointCredentials = System.Text.Json.JsonSerializer.Deserialize(feedEndpointsJson, options);
+ if (endpointCredentials == null)
+ {
+ logger.Verbose(Resources.NoEndpointsFound);
+ return credsResult;
+ }
+
+ foreach (var credentials in endpointCredentials.EndpointCredentials)
+ {
+ if (credentials == null)
+ {
+ logger.Verbose(Resources.EndpointParseFailure);
+ break;
+ }
+
+ if (credentials.ClientId == null)
+ {
+ logger.Verbose(Resources.EndpointParseFailure);
+ break;
+ }
+
+ if (credentials.CertificateSubjectName != null && credentials.CertificateFilePath != null)
+ {
+ logger.Verbose(Resources.EndpointParseFailure);
+ break;
+ }
+
+ if (!Uri.TryCreate(credentials.Endpoint, UriKind.Absolute, out var endpointUri))
+ {
+ logger.Verbose(Resources.EndpointParseFailure);
+ break;
+ }
+
+ var urlEncodedEndpoint = endpointUri.AbsoluteUri;
+ if (!credsResult.ContainsKey(urlEncodedEndpoint))
+ {
+ credsResult.Add(urlEncodedEndpoint, credentials);
+ }
+ }
+
+ return credsResult;
+ }
+ catch (Exception ex)
+ {
+ logger.Verbose(string.Format(Resources.VstsBuildTaskExternalCredentialCredentialProviderError, ex));
+ return new Dictionary(StringComparer.OrdinalIgnoreCase); ;
+ }
+ }
+
+ public static Dictionary ParseExternalFeedEndpointsJsonToDictionary(ILogger logger)
+ {
+ string feedEndpointsJson = Environment.GetEnvironmentVariable(EnvUtil.BuildTaskExternalEndpoints);
+ if (string.IsNullOrWhiteSpace(feedEndpointsJson))
+ {
+ return new Dictionary(StringComparer.OrdinalIgnoreCase);
+ }
+
+ try
+ {
+ logger.Verbose(Resources.ParsingJson);
+ if (feedEndpointsJson.Contains("':"))
+ {
+ logger.Warning(Resources.InvalidJsonWarning);
+ }
+
+ Dictionary credsResult = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ ExternalEndpointCredentialsContainer endpointCredentials = JsonConvert.DeserializeObject(feedEndpointsJson);
+ if (endpointCredentials == null)
+ {
+ logger.Verbose(Resources.NoEndpointsFound);
+ return credsResult;
+ }
+
+ foreach (var credentials in endpointCredentials.EndpointCredentials)
+ {
+ if (credentials == null)
+ {
+ logger.Verbose(Resources.EndpointParseFailure);
+ break;
+ }
+
+ if (credentials.Username == null)
+ {
+ credentials.Username = "VssSessionToken";
+ }
+
+ if (!Uri.TryCreate(credentials.Endpoint, UriKind.Absolute, out var endpointUri))
+ {
+ logger.Verbose(Resources.EndpointParseFailure);
+ break;
+ }
+
+ var urlEncodedEndpoint = endpointUri.AbsoluteUri;
+ if (!credsResult.ContainsKey(urlEncodedEndpoint))
+ {
+ credsResult.Add(urlEncodedEndpoint, credentials);
+ }
+ }
+
+ return credsResult;
+ }
+ catch (Exception ex)
+ {
+ logger.Verbose(string.Format(Resources.VstsBuildTaskExternalCredentialCredentialProviderError, ex));
+ return new Dictionary(StringComparer.OrdinalIgnoreCase);
+ }
+ }
+}
diff --git a/CredentialProvider.Microsoft/Util/SessionTokenCache.cs b/CredentialProvider.Microsoft/Util/SessionTokenCache.cs
index 5370c9fd..f72420ee 100644
--- a/CredentialProvider.Microsoft/Util/SessionTokenCache.cs
+++ b/CredentialProvider.Microsoft/Util/SessionTokenCache.cs
@@ -5,8 +5,8 @@
using System;
using System.Collections.Generic;
using System.IO;
+using System.Text.Json;
using System.Threading;
-using Newtonsoft.Json;
using NuGetCredentialProvider.Logging;
namespace NuGetCredentialProvider.Util
@@ -27,7 +27,7 @@ public SessionTokenCache(string cacheFilePath, ILogger logger, CancellationToken
this.mutexName = @"Global\" + cacheFilePath.Replace(Path.DirectorySeparatorChar, '_');
}
- private Dictionary Cache
+ private Dictionary Cache
{
get
{
@@ -48,7 +48,7 @@ private Dictionary Cache
if (this.cancellationToken.IsCancellationRequested)
{
logger.Verbose(Resources.SessionTokenCacheCancelMessage);
- return new Dictionary();
+ return new Dictionary();
}
}
}
@@ -75,7 +75,7 @@ private Dictionary Cache
public string this[Uri key]
{
- get => Cache[key];
+ get => Cache[key.ToString()];
set
{
bool mutexHeld = false, dummy;
@@ -108,7 +108,7 @@ public string this[Uri key]
mutexHeld = true;
var cache = Cache;
- cache[key] = value;
+ cache[key.ToString()] = value;
WriteFileBytes(Serialize(cache));
}
finally
@@ -124,14 +124,14 @@ public string this[Uri key]
public bool ContainsKey(Uri key)
{
- return Cache.ContainsKey(key);
+ return Cache.ContainsKey(key.ToString());
}
public bool TryGetValue(Uri key, out string value)
{
try
{
- return Cache.TryGetValue(key, out value);
+ return Cache.TryGetValue(key.ToString(), out value);
}
catch (Exception e)
{
@@ -180,7 +180,7 @@ public void Remove(Uri key)
mutexHeld = true;
var cache = Cache;
- cache.Remove(key);
+ cache.Remove(key.ToString());
WriteFileBytes(Serialize(cache));
}
finally
@@ -193,21 +193,19 @@ public void Remove(Uri key)
}
}
- private Dictionary Deserialize(byte[] data)
+ private Dictionary Deserialize(byte[] data)
{
if (data == null)
{
- return new Dictionary();
+ return new Dictionary();
}
- var serialized = System.Text.Encoding.UTF8.GetString(data);
- return JsonConvert.DeserializeObject>(serialized);
+ return JsonSerializer.Deserialize>(data);
}
- private byte[] Serialize(Dictionary data)
+ private byte[] Serialize(Dictionary data)
{
- var serialized = JsonConvert.SerializeObject(data);
- return System.Text.Encoding.UTF8.GetBytes(serialized);
+ return JsonSerializer.SerializeToUtf8Bytes(data);
}
private byte[] ReadFileBytes()
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 47079d55..52100a44 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -22,6 +22,7 @@
+
diff --git a/src/Authentication.Tests/MsalAuthenticationTests.cs b/src/Authentication.Tests/MsalAuthenticationTests.cs
index de5ba758..b7e89e6d 100644
--- a/src/Authentication.Tests/MsalAuthenticationTests.cs
+++ b/src/Authentication.Tests/MsalAuthenticationTests.cs
@@ -79,6 +79,33 @@ public async Task MsalAcquireTokenWithDeviceCodeTest()
Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource);
}
+ [TestMethod]
+ public async Task MsalAquireTokenWithManagedIdentity()
+ {
+ var tokenProvider = new MsalManagedIdentityTokenProvider(app, logger);
+ var tokenRequest = new TokenRequest(PackageUri);
+ tokenRequest.ClientId = Environment.GetEnvironmentVariable("ARTIFACTS_CREDENTIALPROVIDER_TEST_CLIENTID");
+
+ var result = await tokenProvider.GetTokenAsync(tokenRequest);
+
+ Assert.IsNotNull(result);
+ Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource);
+ }
+
+ [TestMethod]
+ public async Task MsalAquireTokenWithServicePrincipal()
+ {
+ var tokenProvider = new MsalServicePrincipalTokenProvider(app, logger);
+ var tokenRequest = new TokenRequest(PackageUri);
+ tokenRequest.ClientId = Environment.GetEnvironmentVariable("ARTIFACTS_CREDENTIALPROVIDER_TEST_CLIENTID");
+ tokenRequest.ClientCertificate = new X509Certificate2(Environment.GetEnvironmentVariable("ARTIFACTS_CREDENTIALPROVIDER_TEST_CERT_PATH") ?? string.Empty);
+
+ var result = await tokenProvider.GetTokenAsync(tokenRequest);
+
+ Assert.IsNotNull(result);
+ Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource);
+ }
+
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
}
diff --git a/src/Authentication.Tests/TokenProviderTests.cs b/src/Authentication.Tests/TokenProviderTests.cs
index a19cc278..e971fa2e 100644
--- a/src/Authentication.Tests/TokenProviderTests.cs
+++ b/src/Authentication.Tests/TokenProviderTests.cs
@@ -93,4 +93,60 @@ public void MsalDeviceCodeFlowContractTest()
tokenRequest.IsInteractive = true;
Assert.IsTrue(tokenProvider.CanGetToken(tokenRequest));
}
+
+ [TestMethod]
+ public void MsalServicePrincipalContractTest()
+ {
+ appMock.Setup(x => x.AppConfig)
+ .Returns(new Mock().Object);
+
+ var tokenProvider = new MsalServicePrincipalTokenProvider(appMock.Object, loggerMock.Object);
+ var tokenRequest = new TokenRequest(PackageUri);
+
+ Assert.IsNotNull(tokenProvider.Name);
+ Assert.IsFalse(tokenProvider.IsInteractive);
+
+ tokenRequest.IsInteractive = true;
+ Assert.IsFalse(tokenProvider.CanGetToken(tokenRequest));
+
+ tokenRequest.IsInteractive = false;
+ tokenRequest.ClientId = "clientId";
+ tokenRequest.ClientCertificate = Mock.Of();
+ Assert.IsTrue(tokenProvider.CanGetToken(tokenRequest));
+
+ tokenRequest.IsInteractive = false;
+ tokenRequest.ClientId = null;
+ tokenRequest.ClientCertificate = Mock.Of();
+ Assert.IsFalse(tokenProvider.CanGetToken(tokenRequest));
+
+ tokenRequest.IsInteractive = false;
+ tokenRequest.ClientId = "clientId";
+ tokenRequest.ClientCertificate = null;
+ Assert.IsFalse(tokenProvider.CanGetToken(tokenRequest));
+ }
+
+
+ [TestMethod]
+ public void MsalManagedIdentityContractTest()
+ {
+ appMock.Setup(x => x.AppConfig)
+ .Returns(new Mock().Object);
+
+ var tokenProvider = new MsalManagedIdentityTokenProvider(appMock.Object, loggerMock.Object);
+ var tokenRequest = new TokenRequest(PackageUri);
+
+ Assert.IsNotNull(tokenProvider.Name);
+ Assert.IsFalse(tokenProvider.IsInteractive);
+
+ tokenRequest.IsInteractive = true;
+ Assert.IsFalse(tokenProvider.CanGetToken(tokenRequest));
+
+ tokenRequest.IsInteractive = false;
+ tokenRequest.ClientId = "clientId";
+ Assert.IsTrue(tokenProvider.CanGetToken(tokenRequest));
+
+ tokenRequest.IsInteractive = false;
+ tokenRequest.ClientId = null;
+ Assert.IsFalse(tokenProvider.CanGetToken(tokenRequest));
+ }
}
diff --git a/src/Authentication.Tests/Usings.cs b/src/Authentication.Tests/Usings.cs
index dc485570..f5a0dc63 100644
--- a/src/Authentication.Tests/Usings.cs
+++ b/src/Authentication.Tests/Usings.cs
@@ -2,6 +2,7 @@
//
// Licensed under the MIT license.
+global using System.Security.Cryptography.X509Certificates;
global using Microsoft.Extensions.Logging;
global using Microsoft.Identity.Client;
global using Microsoft.Identity.Client.Extensions.Msal;
diff --git a/src/Authentication/MsalConstants.cs b/src/Authentication/MsalConstants.cs
index 8b8d21ac..42cb21db 100644
--- a/src/Authentication/MsalConstants.cs
+++ b/src/Authentication/MsalConstants.cs
@@ -6,7 +6,7 @@ namespace Microsoft.Artifacts.Authentication;
public static class MsalConstants
{
- private const string AzureDevOpsResource = "499b84ac-1321-427f-aa17-267ca6975798/.default";
+ public const string AzureDevOpsResource = "499b84ac-1321-427f-aa17-267ca6975798/.default";
public static readonly IEnumerable AzureDevOpsScopes = Array.AsReadOnly(new[] { AzureDevOpsResource });
public static readonly Guid FirstPartyTenant = Guid.Parse("f8cdef31-a31e-4b4a-93e4-5f571e91255a");
diff --git a/src/Authentication/MsalManagedIdentityTokenProvider.cs b/src/Authentication/MsalManagedIdentityTokenProvider.cs
new file mode 100644
index 00000000..f400910a
--- /dev/null
+++ b/src/Authentication/MsalManagedIdentityTokenProvider.cs
@@ -0,0 +1,64 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Identity.Client;
+using Microsoft.Identity.Client.AppConfig;
+
+namespace Microsoft.Artifacts.Authentication;
+
+public class MsalManagedIdentityTokenProvider : ITokenProvider
+{
+ private readonly ILogger logger;
+ private readonly IAppConfig appConfig;
+
+ public MsalManagedIdentityTokenProvider(IPublicClientApplication app, ILogger logger)
+ {
+ this.appConfig = app.AppConfig;
+ this.logger = logger;
+ }
+
+ public string Name => "MSAL Managed Identity";
+
+ public bool IsInteractive => false;
+
+ public bool CanGetToken(TokenRequest tokenRequest) =>
+ !string.IsNullOrWhiteSpace(tokenRequest.ClientId);
+
+ public async Task GetTokenAsync(TokenRequest tokenRequest, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ if (string.IsNullOrWhiteSpace(tokenRequest.ClientId))
+ {
+ logger.LogTrace(string.Format(Resources.MsalClientIdError, tokenRequest.ClientId));
+ return null;
+ }
+
+ IManagedIdentityApplication app = ManagedIdentityApplicationBuilder.Create(CreateManagedIdentityId(tokenRequest.ClientId!))
+ .WithHttpClientFactory(appConfig.HttpClientFactory)
+ .WithLogging(appConfig.LoggingCallback, appConfig.LogLevel, appConfig.EnablePiiLogging, appConfig.IsDefaultPlatformLoggingEnabled)
+ .Build();
+
+ AuthenticationResult result = await app.AcquireTokenForManagedIdentity(MsalConstants.AzureDevOpsResource)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ return result;
+ }
+ catch (MsalServiceException ex) when (ex.ErrorCode is MsalError.ManagedIdentityRequestFailed)
+ {
+ logger.LogTrace(ex.Message);
+ return null;
+ }
+ catch (MsalServiceException ex) when (ex.ErrorCode is MsalError.ManagedIdentityUnreachableNetwork)
+ {
+ logger.LogTrace(ex.Message);
+ return null;
+ }
+ }
+
+ private ManagedIdentityId CreateManagedIdentityId(string clientId)
+ {
+ return Guid.TryParse(clientId, out var id)
+ ? ManagedIdentityId.WithUserAssignedClientId(id.ToString())
+ : ManagedIdentityId.SystemAssigned;
+ }
+}
diff --git a/src/Authentication/MsalServicePrincipalTokenProvider.cs b/src/Authentication/MsalServicePrincipalTokenProvider.cs
new file mode 100644
index 00000000..a3c9973e
--- /dev/null
+++ b/src/Authentication/MsalServicePrincipalTokenProvider.cs
@@ -0,0 +1,57 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Identity.Client;
+
+namespace Microsoft.Artifacts.Authentication
+{
+ public class MsalServicePrincipalTokenProvider : ITokenProvider
+ {
+ public string Name => "MSAL Service Principal";
+
+ public bool IsInteractive => false;
+
+ private readonly ILogger logger;
+ private readonly IAppConfig appConfig;
+
+ public MsalServicePrincipalTokenProvider(IPublicClientApplication app, ILogger logger)
+ {
+ this.appConfig = app.AppConfig;
+ this.logger = logger;
+ }
+
+ public bool CanGetToken(TokenRequest tokenRequest)
+ {
+ return !string.IsNullOrWhiteSpace(tokenRequest.ClientId)
+ && tokenRequest.ClientCertificate != null;
+ }
+
+ public async Task GetTokenAsync(TokenRequest tokenRequest, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ if (!CanGetToken(tokenRequest))
+ {
+ logger.LogTrace("InvalidInputs");
+ return null;
+ }
+
+ var app = ConfidentialClientApplicationBuilder.Create(tokenRequest.ClientId)
+ .WithHttpClientFactory(appConfig.HttpClientFactory)
+ .WithLogging(appConfig.LoggingCallback, appConfig.LogLevel, appConfig.EnablePiiLogging, appConfig.IsDefaultPlatformLoggingEnabled)
+ .WithCertificate(tokenRequest.ClientCertificate, sendX5C: true)
+ .WithTenantId(tokenRequest.TenantId)
+ .Build();
+
+ var result = await app.AcquireTokenForClient(MsalConstants.AzureDevOpsScopes)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ return result;
+ }
+ catch (Exception ex)
+ {
+ logger.LogTrace(ex.Message);
+ return null;
+ }
+ }
+ }
+}
diff --git a/src/Authentication/MsalTokenProviders.cs b/src/Authentication/MsalTokenProviders.cs
index f3d6f1c1..63e81eff 100644
--- a/src/Authentication/MsalTokenProviders.cs
+++ b/src/Authentication/MsalTokenProviders.cs
@@ -11,6 +11,9 @@ public class MsalTokenProviders
{
public static IEnumerable Get(IPublicClientApplication app, ILogger logger)
{
+ yield return new MsalServicePrincipalTokenProvider(app, logger);
+ yield return new MsalManagedIdentityTokenProvider(app, logger);
+
// TODO: Would be more useful if MsalSilentTokenProvider enumerated over each account from the outside
yield return new MsalSilentTokenProvider(app, logger);
diff --git a/src/Authentication/Resources.resx b/src/Authentication/Resources.resx
index 6449a53e..807970dd 100644
--- a/src/Authentication/Resources.resx
+++ b/src/Authentication/Resources.resx
@@ -1,17 +1,17 @@
-
@@ -153,4 +153,7 @@
Cannot use interactive authentication in a non-interactive app
-
+
+ Invalid Client Id {clientId}
+
+
\ No newline at end of file
diff --git a/src/Authentication/TokenRequest.cs b/src/Authentication/TokenRequest.cs
index a0cac518..e5bd873c 100644
--- a/src/Authentication/TokenRequest.cs
+++ b/src/Authentication/TokenRequest.cs
@@ -2,6 +2,7 @@
//
// Licensed under the MIT license.
+using System.Security.Cryptography.X509Certificates;
using Microsoft.Identity.Client;
namespace Microsoft.Artifacts.Authentication;
@@ -31,4 +32,10 @@ public TokenRequest(Uri uri)
public TimeSpan InteractiveTimeout { get; set; } = TimeSpan.FromMinutes(2);
public Func? DeviceCodeResultCallback { get; set; } = null;
+
+ public string? ClientId { get; set; } = null;
+
+ public string? TenantId { get; set; } = null;
+
+ public X509Certificate2? ClientCertificate { get; set; } = null;
}